计算机系统 - 程序执行
目录
目录
本文说明:32 位 vs 64 位
本笔记的汇编示例以 64 位(x86-64) 为主,但课堂演示通常基于 32 位(IA-32)。两者逻辑完全相同,只是寄存器名和地址宽度不同:
32 位(课堂) 64 位(本文) 通用寄存器 %eax %ebx %ecx %edx%rax %rbx %rcx %rdx参数/索引寄存器 %esi %edi%rsi %rdi栈指针 / 帧指针 %esp %ebp%rsp %rbp程序计数器 %eip%rip指针/地址宽度 4 字节 8 字节 栈操作 pushl/poplpushq/popq函数传参方式 全部压栈(无寄存器传参) 前 6 个用寄存器,超出才压栈 switch 跳转表 scale ,4(每项 4 字节),8(每项 8 字节)认准寄存器前缀:
e开头 → 32 位;r开头 → 64 位。逻辑结构一样,换个前缀即可互译。
一、快速回顾:PC 与条件码
PC(程序计数器) 就是 CPU 的 "书签"——始终指向 下一条要执行的指令地址。在 x86 叫 %eip(32位)/ %rip(64位)。CPU 的执行循环本质上就是:取指 → PC 自增 → 执行 → 重复。跳转/调用/返回指令会直接改写 PC 来打破顺序执行。
条件码 是 EFLAGS 寄存器里的标志位(CF/ZF/SF/OF),算术指令执行后自动设置。cmp 和 test 是"只设标志不存结果"的专用指令。程序通过 je/jl/jg 等条件跳转来读取标志位,实现 if/else 和循环控制流。
四个核心条件码
| 标志 | 全称 | 含义 | 何时置 1 |
|---|---|---|---|
| CF | Carry Flag(进位标志) | 无符号运算溢出 | 最高位产生进位/借位,常用于无符号数大小比较和多精度算术 |
| ZF | Zero Flag(零标志) | 结果为零 | 运算结果 = 0,cmp a, b 且 a == b 时置 1 |
| SF | Sign Flag(符号标志) | 结果为负 | 结果的最高位(符号位)= 1,即有符号数为负 |
| OF | Overflow Flag(溢出标志) | 有符号运算溢出 | 正 + 正 = 负 或 负 + 负 = 正,用于有符号数溢出检测 |
条件码 → 条件跳转对照
JX 指令
| 跳转指令 | 含义 | 依赖的标志 |
|---|---|---|
je / jz | 等于 / 结果为零 | ZF = 1 |
jne / jnz | 不等于 | ZF = 0 |
jl / jnge | 有符号小于 | SF ≠ OF |
jle / jng | 有符号小于等于 | ZF = 1 或 SF ≠ OF |
jg / jnle | 有符号大于 | ZF = 0 且 SF = OF |
jge / jnl | 有符号大于等于 | SF = OF |
jb / jnae | 无符号小于(Below) | CF = 1 |
ja / jnbe | 无符号大于(Above) | CF = 0 且 ZF = 0 |
js | 负数 | SF = 1 |
记忆技巧:有符号比较看 SF/OF(
j系列:jl/jg/jle/jge);无符号比较看 CF(j系列用b/a后缀:Below/Above)。cmp a, b实质是计算b - a并设标志,test a, b实质是计算a & b并设标志(常见testl %edi, %edi用来检测某寄存器是否为零)。
深入:cmp A, B 做了什么,CPU 怎么判断大小
cmp A, B 在硬件上执行 B − A,结果只用来设标志,不写回寄存器。
无符号比较:看 CF(进位/借位)
减法在硬件上是加补码:B − A = B + (~A + 1)。如果最高位发生借位(相当于加法产生进位溢出),CPU 置 CF = 1,意味着"减法不够减",即 B < A(无符号)。
| B − A 结果 | CF | ZF | 含义(无符号) |
|---|---|---|---|
| 正数(够减) | 0 | 0 | B > A |
| 恰好为 0 | 0 | 1 | B = A |
| 负数(需借位) | 1 | 0 | B < A |
有符号比较:为什么不能只看 SF,还要配合 OF
正常情况下,SF 反映结果的符号位:SF = 1 说明 B − A < 0,即 B < A。
但当结果超出有符号数的表示范围时(发生溢出,OF = 1),符号位被"截断"成了错误的值,这时 SF 反映的是截断后的假符号,必须用 SF XOR OF 才能还原真实大小关系。
| SF | OF | SF XOR OF | 有符号含义 |
|---|---|---|---|
| 0 | 0 | 0 | B − A ≥ 0,无溢出,B ≥ A |
| 1 | 0 | 1 | B − A < 0,无溢出,B < A |
| 0 | 1 | 1 | 溢出!符号位被翻转成 0,实际 B − A < 0,B < A |
| 1 | 1 | 0 | 溢出!符号位被翻转成 1,实际 B − A > 0,B > A |
因此 jl(有符号小于 <)的判断条件就是 SF ≠ OF,jge (有符号大于 >)的条件是 SF = OF。
有 OF 参与的比较一般都是有符号数的比较。
两个溢出的具体例子(用 8 位演示)
例 1:SF=1, OF=1 → 实际 B > A
B = 127(0111 1111),A = −2(1111 1110),计算 B − A = 127 − (−2) = 129,超出 8 位有符号上限 127。
0111 1111 (127)
+ 0000 0010 (~(-2)+1 = 2 的补码)
-----------
1000 0001 = -127 (8位截断后)
最高位进位舍去(CF=1),存储结果 1000 0001 = −127,SF = 1(显示"负数");但真实结果是正数 +129,所以 OF = 1。SF = OF = 1 → jg 条件成立,正确判断 B > A。
例 2:SF=0, OF=1 → 实际 B < A
B = −128(1000 0000),A = 1(0000 0001),计算 B − A = −128 − 1 = −129,超出 8 位有符号下限 −128。
1000 0000 (-128)
+ 1111 1111 (~1+1 = -1 的补码)
-----------
0111 1111 = +127 (8位截断后,最高位进位舍去)
最高位进位舍去(CF=1),存储结果 0111 1111 = +127,SF = 0(显示"正数");但真实结果是负数 −129,所以 OF = 1。SF ≠ OF → jl 条件成立,正确判断 B < A。
结论:CPU 的设计精妙之处在于,无论有没有溢出,只要检查 SF XOR OF,就能得到有符号比较的正确结论。硬件不需要知道"发没发生溢出",只需读两个标志位做 XOR 即可。
setX 指令:把条件码"物化"成一个字节
jX 是根据条件码跳转,setX 则是根据同一套条件码写入 0 或 1 到一个单字节寄存器。两者依赖的条件完全相同,只是动作不同。
sete %al # ZF=1 → al=1,否则 al=0
setl %al # SF≠OF → al=1,否则 al=0
setg %al # ZF=0 且 SF=OF → al=1,否则 al=0
常用 setX 速查:
| 指令 | 含义 | 条件 |
|---|---|---|
sete / setz | 等于 | ZF = 1 |
setne / setnz | 不等于 | ZF = 0 |
setl / setnge | 有符号小于 | SF ≠ OF |
setle / setng | 有符号小于等于 | ZF = 1 或 SF ≠ OF |
setg / setnle | 有符号大于 | ZF = 0 且 SF = OF |
setge / setnl | 有符号大于等于 | SF = OF |
setb / setnae | 无符号小于 | CF = 1 |
seta / setnbe | 无符号大于 | CF = 0 且 ZF = 0 |
sets | 负数 | SF = 1 |
setX 的典型用途:布尔表达式赋值
C 语言里的比较表达式(a > b、x == y)会产生 0 或 1,编译器用 setX 来实现:
int is_greater(int a, int b) {
return a > b;
}
is_greater:
cmpl %esi, %edi # a - b,设标志(注意:edi=a, esi=b,计算 a-b)
setg %al # al = (a > b) ? 1 : 0
movzbl %al, %eax # 零扩展到 32 位(清掉 eax 高位)
ret
%eax是 32 位寄存器,setX 只往最低的 1 个字节(%al)写 0 或 1,剩下 3 个字节原封不动。movzbl %al, %eax就是把高 3 字节清零,防止之前残留的值混进来污染返回结果。
更多细节参见 基础认知 中的寄存器和条件跳转章节。
二、C → 汇编:编译器到底把代码变成了什么
这一章是核心。我们不光要知道"C 能编译成汇编",更要掌握 各种 C 构造编译后长什么样。
2.1 变量赋值与算术
最简单的起点:
int a = 3;
int b = a + 5;
movl $3, -4(%rbp) # a = 3(写入栈 rbp-4)
movl -4(%rbp), %eax # eax = a(从栈读到寄存器)
addl $5, %eax # eax = eax + 5 = 8
movl %eax, -8(%rbp) # b = 8(写回栈 rbp-8)
观察几件事:
- 局部变量住在 栈上,用
rbp - 偏移量定位 - 运算不能直接在内存上做(x86 有些能,但编译器更常 "搬进寄存器 → 算 → 搬回去")
%eax是万能打工人——大量中间计算都通过它
2.2 函数调用的完整画面
int add(int x, int y) {
return x + y;
}
int main() {
int result = add(3, 5);
return result;
}
add:
pushq %rbp # ① 保存调用方栈帧
movq %rsp, %rbp # ② 建立自己的栈帧
movl %edi, -4(%rbp) # ③ 把参数 x 从寄存器存到栈上
movl %esi, -8(%rbp) # ④ 把参数 y 从寄存器存到栈上
movl -4(%rbp), %eax # ⑤ eax = x
addl -8(%rbp), %eax # ⑥ eax += y
popq %rbp # ⑦ 恢复栈帧
ret # ⑧ 返回(eax 里就是返回值)
main:
pushq %rbp
movq %rsp, %rbp
movl $3, %edi # 第 1 参数 → %edi
movl $5, %esi # 第 2 参数 → %esi
call add # 压入返回地址,跳到 add
movl %eax, -4(%rbp) # result = 返回值(从 %eax 取)
movl -4(%rbp), %eax # 把 result 放回 eax 作为 main 的返回值
popq %rbp
ret
寄存器约定速记
| 角色 | 寄存器 | 记忆 |
|---|---|---|
| 返回值 | %eax / %rax | 函数的"出口",所有函数返回值都在这 |
| 第 1 参数 | %edi / %rdi | 6 个参数按顺序用 di, si, dx, cx, r8, r9 |
| 第 2 参数 | %esi / %rsi | |
| 第 3 参数 | %edx / %rdx | |
| 第 4 参数 | %ecx / %rcx | |
| 第 5~6 参数 | %r8d / %r9d | |
| ≥7 个参数 | 栈传递 | 逆序压栈 |
| 栈顶指针 | %rsp | 永远指向栈顶 |
| 栈帧基址 | %rbp | 当前函数栈帧的 "锚点" |
核心规则:返回值永远在
%eax(32位)/%rax(64位)。 这就是为什么你看到每个函数末尾都在往%eax里塞东西。
2.3 swap——经典的指针操作
void swap(int *a, int *b) {
int tmp = *a;
*a = *b;
*b = tmp;
}
swap:
movl (%rdi), %eax # tmp = *a(rdi 是第1参数:指针 a)
movl (%rsi), %edx # edx = *b(rsi 是第2参数:指针 b)
movl %edx, (%rdi) # *a = *b(往 a 指向的地址写入 b 的值)
movl %eax, (%rsi) # *b = tmp(往 b 指向的地址写入原来的 a)
ret
swap 是理解指针操作汇编的绝佳例子:
(%rdi)= 解引用指针 a(去 rdi 存的地址取值),(%rsi)= 解引用指针 b。括号 = 解引用,没有括号 = 地址本身。注意编译器发现tmp可以直接用%eax暂存,根本不需要分配栈空间。
2.4 if-else
int max(int a, int b) {
if (a > b)
return a;
else
return b;
}
max:
cmpl %esi, %edi # a - b,设标志(不存结果)
jle .L_else # a ≤ b → 跳到 else
movl %edi, %eax # return a(放进 eax)
ret
.L_else:
movl %esi, %eax # return b(放进 eax)
ret
编译器把
if (a > b)翻转成jle(≤ 就跳走),让 then 分支紧跟在cmp后面——不跳转 = 走 then,这对 CPU 分支预测更友好。
2.5 循环
while 循环
int sum(int n) {
int s = 0;
while (n > 0) { s += n; n--; }
return s;
}
sum:
movl $0, %eax # s = 0
jmp .L_test # 先跳去检查条件
.L_body:
addl %edi, %eax # s += n
decl %edi # n--
.L_test:
testl %edi, %edi # n 和自己按位与 → 设 ZF
jg .L_body # n > 0 → 回去执行循环体
ret # s 已经在 eax 里,直接返回
do-while 循环
int sum_do(int n) {
int s = 0;
do { s += n; n--; } while (n > 0);
return s;
}
sum_do:
movl $0, %eax # s = 0
.L_body:
addl %edi, %eax # s += n ← 直接从循环体开始,没有前置跳转
decl %edi # n--
testl %edi, %edi # 条件检查放在末尾
jg .L_body # n > 0 → 继续循环
ret
对比 while 的汇编,差别一目了然:
| while | do-while | |
|---|---|---|
| 入口 | jmp .L_test(先跳去检查条件) | 直接进 .L_body(无前置跳转) |
| 条件检查位置 | 循环体之前 | 循环体之后 |
| n=0 时的行为 | 一次都不执行 | 执行一次再检查,结果可能出错 |
| 指令数 | 多一条前置 jmp | 少一条指令,略微更高效 |
关键:do-while 不保护 n=0 的情况
// while:n=0 时 s=0,正确
int s = 0;
while (n > 0) { s += n; n--; }
// do-while:n=0 时执行了 s += 0; n-- → n=-1,再判断 -1>0 为假退出
// 结果 s=0 碰巧对,但 n 被改成了 -1,副作用已经发生
int s = 0;
do { s += n; n--; } while (n > 0);
正因如此,编译器把 while 翻译成 "jump-to-middle"(先跳到末尾检查条件)而不是直接翻译成 do-while,就是为了保留"可能一次都不执行"的语义。
for 循环
编译器先将 for 拆成 while,然后用同样的 "jump-to-middle" 模式。
2.6 goto 与无条件跳转 jmp
C 的 goto 是唯一直接对应汇编 jmp 的构造——无条件跳到标签,不判断任何条件。
int count_down(int n) {
int s = 0;
loop:
if (n <= 0) goto done;
s += n;
n--;
goto loop;
done:
return s;
}
count_down:
movl $0, %eax # s = 0
loop:
testl %edi, %edi # n & n,测试 n 是否 ≤ 0
jle done # n ≤ 0 → 跳到 done
addl %edi, %eax # s += n
decl %edi # n--
jmp loop # 无条件跳回 loop
done:
ret
为什么要了解 goto
实际写 C 代码几乎不用 goto,但它在这里有两个重要意义:
-
编译器的内部语言:编译器把所有控制流(if/while/for/switch)先转换成 "goto 形式"(也叫 goto code)再生成汇编。理解 goto ↔ jmp 的对应,就理解了所有控制流的汇编本质。
-
直接跳转 vs 间接跳转:
jmp有两种形式:jmp .label:直接跳转,目标地址编译期确定,对应 goto/if/whilejmp *%rax:间接跳转,运行时才知道跳哪,对应switch的跳转表和函数指针调用
switch 的跳转表就是间接跳转的典型应用
int switch_demo(int x) {
int result;
switch (x) {
case 1: result = 10; break;
case 2: result = 20; break;
case 3: result = 30; break;
default: result = 0;
}
return result;
}
switch_demo:
cmpl $3, %edi # x > 3?
ja .default # 超出范围 → default(无符号比较,负数也走这里)
movslq %edi, %rdi # x 符号扩展到 64 位(用作表下标)
jmp *.Ltable(,%rdi,8) # 间接跳转:跳到 table[x] 存的地址
.Ltable: # 跳转表,每项 8 字节(64 位地址)
.quad .default # x=0 → default
.quad .case1 # x=1
.quad .case2 # x=2
.quad .case3 # x=3
.case1:
movl $10, %eax
ret
.case2:
movl $20, %eax
ret
.case3:
movl $30, %eax
ret
.default:
movl $0, %eax
ret
关键点:
- 跳转表是一个地址数组,存在只读数据段(
.rodata),每项是一个代码标签的地址 jmp *.Ltable(,%rdi,8)的寻址:基址.Ltable+ 下标%rdi× 8(每项 8 字节),取出地址后*解引用跳过去ja .default先做范围检查,用无符号比较(ja),这样负数(如 x = −1)转成无符号后是很大的数,也会跳到 default,一条指令同时处理"越界"和"负数"两种情况- case 数量越多,跳转表优势越明显:逐个
cmp+je是 O(n),跳转表是 O(1)
break 与 fall-through
fall-through 在 C 里只有一种写法(不写 break),但在汇编层面其实分两种情况,跳转表的行为完全不同。
情况一:有 break(各 case 独立)
switch (x) {
case 1: result = 10; break;
case 2: result = 20; break;
case 3: result = 30; break;
}
跳转表各项指向不同地址,每个 case 末尾有 jmp .end 跳出:
.Ltable:
.quad .case1
.quad .case2
.quad .case3
.case1:
movl $10, %eax
jmp .end # break → jmp .end
.case2:
movl $20, %eax
jmp .end
.case3:
movl $30, %eax
jmp .end
.end:
情况二:有代码,无 break(非空贯穿)
switch (x) {
case 1: result = 10; // 有代码,无 break
case 2: result = 20; // 有代码,无 break
case 3: result = 30; break;
}
跳转表各项仍指向不同地址,但 case 之间没有 jmp,代码在内存中顺序相邻,执行完自然"落入"下一个 case:
.Ltable:
.quad .case1 # 各自不同的地址
.quad .case2
.quad .case3
.case1:
movl $10, %eax # 执行完,无 jmp,顺序落入 case2
.case2:
movl $20, %eax # 执行完,无 jmp,顺序落入 case3
.case3:
movl $30, %eax
jmp .end # 只有最后才跳出
.end:
x=1 时三条 movl 全部执行,result 最终为 30。
情况三:无代码,无 break(空合并)
switch (x) {
case 1: // 无任何代码,无 break
case 2: // 无任何代码,无 break
case 3: result = 30; break;
}
case 1、2 没有对应代码,编译器直接让跳转表里的多项指向同一个地址,.case1 和 .case2 标签根本不存在:
.Ltable:
.quad .case3 # x=1 → 直接指向 case3 的地址
.quad .case3 # x=2 → 同一个地址
.quad .case3 # x=3
.case3:
movl $30, %eax
jmp .end
.end:
这就是课堂上说的"跳转表内部地址一样"——不是代码顺序相邻落入,而是跳转表里直接存了相同的目标地址,x=1 和 x=3 走的是同一条路径。
情况四:带数据冲突的 fall-through(提取公共块)
当 case A 贯穿到 case B,但 case B 开头有独立的初始化赋值,物理顺序落入会把 case A 算好的值直接覆盖掉。这时编译器会提取公共后缀块,用一条内部 jmp 绕过冲突:
switch (x) {
case 1:
val = compute(a, b); // case 1 算出 val
// fall through,想复用 case 2 后半段的逻辑
case 2:
val = a + b; // case 2 的初始化——会覆盖 case 1 的 val!
result = val * 2; // 两者共享的后续逻辑
break;
}
直接贯穿的话,x=1 走到 val = a + b 时 case 1 的结果就被抹掉了。编译器的解法是把共享的 result = val * 2 提取成独立标签 .Lshared:
.Ltable:
.quad .case1
.quad .case2
.case1:
# val = compute(a, b)
jmp .Lshared # ← 显式跳过 case2 的初始化,直奔共享块
.case2:
# val = a + b (case2 自己的初始化)
# 无 jmp,物理贯穿落入 .Lshared
.Lshared:
# result = val * 2 (公共后缀逻辑)
jmp .end
.end:
执行结果:
- x=1:
val = compute(a,b),跳过初始化,执行result = val * 2 - x=2:
val = a + b,落入result = val * 2
识别特征:即使没有 break,汇编里出现了跨越部分 case 逻辑的内部 jmp(不是跳出整个 switch 的 jmp .end,而是跳进 switch 内部某个标签)。
四种情况对比
| 写法 | 跳转表 | case 之间 |
|---|---|---|
| 有 break | 各项地址不同 | 每个 case 末尾有 jmp .end |
| 有代码无 break | 各项地址不同 | case 之间无跳转,顺序落入 |
| 无代码无 break | 多项地址相同 | 无独立代码块,共用同一入口 |
| 有代码无 break 但有数据冲突 | 各项地址不同 | case A 末尾有内部 jmp 跳入公共块,case B 物理落入公共块 |
2.7 数组的内存表示与访问
1. 一维数组(基本原则)
对于声明 T A[N];:
- 在内存中分配一段 字节的连续空间( 是数据类型 的大小)。
- 数组名
A可作为指向数组开头的指针。
char A[12]; // 连续 12 字节
int B[8]; // 连续 32 字节 (8 * 4)
double C[6]; // 连续 48 字节 (6 * 8)
2. typedef 声明的数组
typedef 并不会改变数组的底层内存表示概念,它只是给特定类型的数组起了个别名,方便代码复用或增加可读性。
typedef int zip_dig[5]; // 定义一个新类型 zip_dig,它是一个包含 5 个 int 的数组
zip_dig cmu = {1, 5, 2, 1, 3};
// cmu 在内存中就是连续紧靠的 5 个 int(共占 20 字节)
// cmu 的底层表现和普通的 int cmu[5] 完全相同
3. 嵌套的数组(Nested Arrays)/ 二维数组
C 语言的多维数组在内存中是 按行优先(Row-Major Order) 连续存储的。它本质上就是“数组的数组”(也就是嵌套数组),而不是像某些高级语言那样是“指针的指针”。
如果结合刚才的 typedef,嵌套数组在内存中“同构平坦”的特点会非常直观:
typedef int zip_dig[5]; // 每个 zip_dig 是连续的 5 个 int(20 字节)
zip_dig pgh[4] = { // pgh 是一个包含 4 个 zip_dig 的嵌套数组
{1, 5, 2, 0, 6}, // 第 0 行
{1, 5, 2, 1, 3}, // 第 1 行
{1, 5, 2, 1, 7}, // 第 2 行
{1, 5, 2, 2, 7} // 第 3 行
};
// 整个 pgh 在内存中是 4 * 20 = 80 字节的绝对连续空间
这也等价于直接声明 int pgh[4][5];。
对于一般的多维数组声明 T D[R][C];:
- 分配大小为 字节的连续内存。
D[i][j]的寻址,其实就是先跳过i个完整的行(每行 字节),再跳过j个元素本身(每个 字节)。- 得出内存地址计算公式:
int matrix[3][4]; // 3 行 4 列的 int 数组,共占 12 * 4 = 48 字节内存
它的内存布局展开成一维:
| 行偏移 | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| 第0行 | [0][0] | [0][1] | [0][2] | [0][3] |
| 第1行 | [1][0] | [1][1] | [1][2] | [1][3] |
| 第2行 | [2][0] | [2][1] | [2][2] | [2][3] |
4. 指针数组(Array of Pointers / 多级数组)
这就是容易和“嵌套数组(二维数组)”混淆的概念——事先声明了几个独立的数组,然后用另一个数组里的元素作为指针,分别指向它们的各自的首地址。
int row1[4] = {1, 5, 2, 6};
int row2[4] = {1, 5, 2, 3};
int row3[4] = {1, 5, 2, 7};
int *univ[3] = {row1, row2, row3}; // 这是一个包含 3 个指针的数组
🔑 核心区分:内存连续性与访存次数
- 连续性:
int pgh[3][4]必然是一大块连续的12*4=48字节的内存。而univ[3]指向的row1、row2、row3在内存空间中可能散落各处,并不连续。- 访存次数:要访问二维数组
pgh[i][j],CPU 只需要通过公式算出地址,做一次寻址(Mem 读)就能拿到结果。而要访问指针数组univ[i][j],CPU 必须做两次寻址(Mem 读):第一次先从univ里读出row的指针首地址,第二次拿这个地址加上物理偏移量再去读具体元素。
5. 汇编层面的数组与常规访问
在数组访问转化为汇编时,编译器最常使用的就是变址寻址:
int get_elem(int arr[], int i) {
return arr[i];
}
get_elem:
movslq %esi, %rsi # i 符号扩展到 64 位
movl (%rdi, %rsi, 4), %eax # eax = *(arr + i*4) = arr[i]
ret
(%rdi, %rsi, 4)三件套:首地址 + 下标 × 元素大小。C 里arr[i]这种看似高级的下标访问,在底层只不过是*(arr + i)的语法糖,生成的汇编指令完全一致。
6. 定长数组的编译器优化(Fixed-Size Array Optimization)
当数组的维度在编译期是已知常量时(定长数组),现代编译器往往能做极其强力的优化。它会直接剥离掉公式 X_D + L * (C * i + j) 里昂贵的乘法计算,将其转换为廉价的指针加法(这种优化手段叫作 强度削弱 Strength Reduction)。
来看一个经典的提取矩阵对角线元素求和的例子:
#define N 16
typedef int fix_matrix[N][N];
int diag_sum(fix_matrix A) {
int sum = 0;
for (int i = 0; i < N; i++) {
sum += A[i][i]; // 访存公式本应该是:A + 4 * (16 * i + i) = A + 68 * i
}
return sum;
}
在开启了优化的编译器眼中(哪怕只是 -O1),它绝不会在循环里每次都傻乎乎地去算 16 * i + i。编译器会自动把它转化成等价的指针运算:
// 编译器"脑补"出优化后的等价 C 代码
int diag_sum_opt(fix_matrix A) {
int sum = 0;
int *ptr = &A[0][0]; // 搞一个指针顺着内存爬
int *end = &A[N][0]; // 算出结束地址当作终止条件
do {
sum += *ptr;
ptr += (N + 1); // 绝杀!对角线元素在平坦内存里相差正好 17 个位置!
} while (ptr != end);
return sum;
}
对应核心汇编循环(剥离了原本的乘法寻址):
.L4:
addl (%rax), %edx # sum += *ptr
addq $68, %rax # ptr += 17 (即 68 字节)
cmpq %rcx, %rax # ptr == end?
jne .L4 # 否则继续循环
原本循环每次需要用 imul 来做乘法算下标,现在被彻底优化成了一条 addq $68, %rax。这就是定长数组在运算速度上的杀手锏。
2.8 结构体的内存表示与访问
理解 C 语言结构体的核心在两点:偏移量 和 数据对齐。
1. 结构体的内存布局
结构体的所有字段打包在一段连续字节中。编译器在编译期会计算并维护每个字段相对于起始地址的字节偏移量。
struct rec {
int i; // 偏移 0 (占 4 字节)
int j; // 偏移 4 (占 4 字节)
int a[2]; // 偏移 8 (占 8 字节)
int *p; // 偏移 16 (占 8 字节)
}; // 总大小: 24 字节
要访问 r->j,编译器会直接使用偏移量 4。
2. 汇编层面的结构体访问
结构体访问在底层极其简单,本质就是 基址 + 固定偏移量:
struct Point { int x; int y; };
int get_y(struct Point *p) { return p->y; }
get_y:
movl 4(%rdi), %eax # eax = *(p + 4) = p->y(偏移 4 固化在指令中)
ret
汇编代码中不存在结构体的字段名和类型。编译后留下的只有硬编码的数字(偏移量)。
3. 数据对齐与内部填充
总线一次访存通常要求数据的存放地址是其自身大小的整数倍(即 字节对齐)。
为了满足对齐要求,编译器会在字段之间自动插入空白的内部填充。
struct S1 {
char c; // 1 字节
// 👈 强行插入 3 字节内部填充,让后续的 int 满足 4 字节对齐
int i[2]; // 8 字节
double v; // 8 字节
};
不考虑对齐只需要 17 字节,算上内部填充后占据 20 字节。
4. 外部填充与总大小
结构体末尾往往还需要外部填充。
规则:结构体的总大小,必须是它所有成员中最大对齐要求的倍数。以保证如果将该结构体作为数组元素,每个元素的起始地址都能天然对齐。
上方 S1 的最大对齐成员是 double(要求 8 字节对齐)。前面已经占据 20 字节,为了成为 8 的倍数,结尾必须加上 4 个外部填充字节,因此 S1 实际被设定为 24 字节。
5. 字段重排优化
C 语言标准不允许编译器擅自改变字段的声明顺序。程序员可以通过按数据类型从大到小依次声明字段,手动消除填充空隙,大幅减小结构体体积:
// ❌ 随意排列:大小 24 字节
struct Bad {
char c1; // 1 字节 + 7 字节填充
double d; // 8 字节
char c2; // 1 字节 + 7 字节填充
};
// ✅ 从大到小排列:大小 16 字节
struct Good {
double d; // 8 字节
char c1; // 1 字节
char c2; // 1 字节 + 末尾统一补 6 字节外部填充
};
2.9 联合(Unions)的内存表示
联合(union)在语法上与 struct 极其相似,但底层内存逻辑截然相反:
- 结构体 (
struct):各个字段依次排列,依序占据不同地址段。 - 联合 (
union):所有字段完全重叠,全体从偏移量 0 的首地址开始共享同一块内存。
总大小:仅仅取决于占据空间最大的那一个字段(同时也要满足最大对齐要求)。
union U1 {
char c; // 偏移 0 (占 1 字节)
int i[2]; // 偏移 0 (占 8 字节)
double v; // 偏移 0 (占 8 字节)
}; // 总大小:8 字节(最大字段大小)
汇编视角:处理
u->c、u->i[0]或u->v时,汇编指令去读取的内存地址完全一样(基址 + 偏移量 0),唯一的区别只在于指令读取宽度的不同(比如取 1 字节movb还是 8 字节movq)。
联合的核心用途:直接解读位模式(Type Punning)
因为多字段共享同一块内存,这允许程序员绕过类型系统,直接以另一种数据类型去“强行解读”同一套二进制比特流。
// 提取 double 浮点数的底层 64 位 IEEE 754 二进制模式
unsigned long double_to_bits(double d) {
union {
double d;
unsigned long u;
} temp;
temp.d = d;
return temp.u; // 直接用整型的视角拿走浮点数的原始比特流
}
这种写法生成的指令极为干净,没有任何浮点转换开销。硬件层面也只是将数据压进并原封不动地拔出寄存器而已。
2.10 条件传送(CMOVcc)——无分支的 if
cmovXX src, dst 的语义:先无条件计算两个分支的值,再根据条件码决定要不要把 src 搬进 dst。整个过程没有跳转指令,CPU 流水线不需要做分支预测。
CMOVcc 指令速查
后缀命名规则与 jXX 完全相同:
| 指令 | 含义 | 条件 |
|---|---|---|
cmove / cmovz | 等于时传送 | ZF = 1 |
cmovne / cmovnz | 不等于时传送 | ZF = 0 |
cmovl / cmovnge | 有符号小于时传送 | SF ≠ OF |
cmovle / cmovng | 有符号小于等于时传送 | ZF = 1 或 SF ≠ OF |
cmovg / cmovnle | 有符号大于时传送 | ZF = 0 且 SF = OF |
cmovge / cmovnl | 有符号大于等于时传送 | SF = OF |
cmovb / cmovnae | 无符号小于时传送 | CF = 1 |
cmova / cmovnbe | 无符号大于时传送 | CF = 0 且 ZF = 0 |
cmovs | 负数时传送 | SF = 1 |
cmovns | 非负时传送 | SF = 0 |
典型示例
int abs_val(int x) { return (x >= 0) ? x : -x; }
abs_val:
movl %edi, %eax # eax = x(先准备"then"分支的值)
negl %edi # edi = -x(先准备"else"分支的值)
testl %eax, %eax # 测试 x 的符号
cmovs %edi, %eax # 若 SF=1(x<0),eax = -x;否则 eax 保持 x
ret
注意编译器两个分支都先算好,再用 cmov 选一个——这是与 jXX 的本质区别。
再看一个 max:
int max(int a, int b) { return (a > b) ? a : b; }
max:
cmpl %esi, %edi # a - b
movl %esi, %eax # eax = b(先假设结果是 b)
cmovg %edi, %eax # 若 a > b,eax = a
ret
优势与代价
优势:消除分支预测失败的惩罚
现代 CPU 是乱序超标量流水线,每条指令可能提前 10~20 个周期就进入流水线。遇到条件跳转,CPU 必须猜测走哪条路(分支预测)。猜错了,已经执行到一半的指令全部作废,重新填充流水线,这叫分支预测失败惩罚,通常损失 15~20 个时钟周期。
cmov 不跳转,CPU 不需要预测,流水线始终满载运行。
jXX 路径(预测失败时):
取指 → 译码 → 执行 → [猜错!冲刷流水线] → 重新取指 → ... ≈ +15 周期惩罚
cmov 路径:
两个分支同时计算 → cmov 选择结果 → 无任何惩罚
代价:必须无条件计算两个分支
cmov 要求两个分支的值都提前算好,这带来两个限制:
-
有副作用的分支不能用:如果分支里有写内存、调函数等操作,不能事先"都执行一遍"
例 1:空指针解引用
int val = (p != NULL) ? *p : 0;程序员的意图是:p 为 NULL 时直接返回 0,绝不去碰
*p。但若编译器用 cmov:movl $0, %eax # 先算 else:0 movl (%rdi), %ecx # 先算 then:*p ← p==NULL 时这里直接段错误! testq %rdi, %rdi cmovne %ecx, %eax # 再选:p!=NULL 就用 *p,否则用 0movl (%rdi), %ecx是普通内存读,不管条件真假都会执行。p 是 NULL 时解引用地址 0,操作系统直接杀掉进程(Segmentation Fault)。所以编译器遇到这类写法会放弃 cmov,改回 jXX。例 2:除零
int result = (b != 0) ? (a / b) : 0;用 cmov 的话,
a / b这条除法指令无论条件真假都会执行。b == 0 时触发 CPU 除零异常,程序崩溃。同样,编译器不会对这种表达式生成 cmov。 -
计算代价不对称时可能更慢:两个分支都要算,如果其中一个很耗时(比如除法、内存访问),即使最终不用它的结果,时间也已花掉。此时不如用跳转直接跳过那个分支。
编译器的选择原则:分支预测容易命中(如循环计数器)→ 用 jXX;分支方向难以预测(如比较随机数据)→ 用 cmov。开
-O2后编译器会自动判断,不需要手动干预。
2.10 操作数组合总览
x86 的 mov 指令支持哪些 source → dest 组合?唯一的禁区是 Mem → Mem(不能一条指令直接在两个内存地址之间搬数据)。
flowchart LR
subgraph Source
I["Imm(立即数)"]
R["Reg(寄存器)"]
M["Mem(内存)"]
end
subgraph "Src, Dest"
IR["movl $0x4, %eax"]
IM["movl $0x4, (%rdi)"]
RR["movl %eax, %edx"]
RM["movl %eax, (%rdi)"]
MR["movl (%rdi), %eax"]
end
subgraph "C Analog"
IR_C["a = 4"]
IM_C["*p = 4"]
RR_C["d = a"]
RM_C["*p = a"]
MR_C["a = *p"]
end
I --> IR --> IR_C
I --> IM --> IM_C
R --> RR --> RR_C
R --> RM --> RM_C
M --> MR --> MR_C
M -.->|"❌ 不允许"| X["Mem → Mem"]
style X fill:#7f1d1d,color:#fca5a5,stroke:#ef4444
如果确实需要 Mem → Mem(比如
memcpy),必须拆成两步:先 Mem → Reg,再 Reg → Mem。
附:肉眼反编译速查
按类别列出"关键指纹",看到对应特征直接还原 C 结构。
控制流
| 关键特征 | C 构造 |
|---|---|
开头有 jmp .test,条件跳转在末尾 | while / for |
| 无前置跳转,直接进循环体,条件跳转在末尾 | do-while |
cmp + jXX 跳过一段代码 | if-else |
ja .default + jmp *.table(,%rdi,8)(64位)/ jmp *.table(,%eax,4)(32位) | switch(跳转表) |
单条 jmp .label,无条件 | goto / 循环回跳 |
cmovXX 且全程无跳转指令 | 三目运算符 ? : |
函数调用
| 关键特征 | C 构造 |
|---|---|
pushq %rbp / movq %rsp, %rbp 开头 | 函数定义(栈帧建立) |
call 前依次填 edi/esi/edx/ecx(64位)/ call 前逆序 pushl 参数(32位) | 函数传参 |
函数末尾结果在 %eax / %rax | 返回值 |
数据访问
| 关键特征 | C 构造 |
|---|---|
movl N(%rdi), %eax,N 是常量 | 结构体字段 p->field,N 是字段偏移 |
movslq + (%base,%idx,scale) 寻址 | 数组下标 arr[i],scale 是元素大小 |
leaq 但没有实际读写内存 | 地址计算或快速乘法(如 i * 4) |
setXX %al + movzbl %al, %eax | 布尔赋值 int b = (a > x) |
while vs do-while
flowchart LR
subgraph W["while 循环"]
direction TB
W1(["入口"]) --> W2["jmp .test"]
W2 --> W3["条件检查"]
W3 -->|"条件为真"| W4["循环体"]
W4 --> W3
W3 -->|"条件为假"| W5(["退出"])
end
subgraph D["do-while 循环"]
direction TB
D1(["入口"]) --> D2["循环体"]
D2 --> D3["条件检查"]
D3 -->|"条件为真"| D2
D3 -->|"条件为假"| D4(["退出"])
end
if-else vs switch
flowchart LR
subgraph I["if-else"]
direction TB
I1["cmp / test"] --> I2{"jXX .else"}
I2 -->|"不跳(then)"| I3["then 分支"]
I2 -->|"跳转(else)"| I4["else 分支"]
I3 --> I5["jmp .end"]
I4 --> I5
end
subgraph S["switch 跳转表"]
direction TB
S1["cmpl $N, %edi"] --> S2{"ja .default\n范围检查"}
S2 -->|"越界/负数"| S5["default"]
S2 -->|"在范围内"| S3["jmp *.table(,%rdi,8)\n间接跳转"]
S3 --> S4["case 1 / 2 / 3 ..."]
end
看到
jmp *就是 switch;看到jmp .L_test开头就是 while/for;看到没有任何跳转只有 cmov 就是三目运算符。
三、内存中的操作细节
3.1 栈帧的建立与销毁
栈 vs 栈帧:两个不同粒度的概念
栈(Stack) 是整块内存区域——程序启动时 OS 分配好的,专门用来支撑所有函数调用,%rsp 始终指向它的当前顶端。
栈帧(Stack Frame) 是栈里属于某一次函数调用的那一段切片。每次调用函数,就在栈上为这次调用划出一块专属空间;函数返回,这块空间即释放。
栈 = 整栋公寓楼(整块内存)
栈帧 = 每个住户的一套房间(某次函数调用的专属空间)
两个关键指针各司其职:
| 指针 | 名称 | 作用 |
|---|---|---|
%rsp | 栈顶指针 | 追踪整个栈的当前顶端,随 push/pop 不断移动 |
%rbp | 帧基址指针 | 固定在当前栈帧的起点,作为定位局部变量的锚点 |
%rbp 存在的意义:函数执行期间 %rsp 会随 push/pop 上下浮动,但 %rbp 钉死不动,所以 rbp-4、rbp-8 可以稳定地寻址局部变量。
调用栈的动态变化
以 main() 调用 add() 为例,三个时刻的栈状态:
block-beta
columns 3
block:B1["① 调用前"]:1
columns 1
b1a["main 的局部变量"]
b1b["← %rbp(main 帧锚点)"]
b1c["← %rsp"]
end
block:B2["② call add 之后"]:1
columns 1
b2a["main 的局部变量"]
b2b["← main 的 %rbp"]
b2c["返回地址(自动压入)"]
b2d["旧 %rbp(push 保存)"]
b2e["← %rbp(add 帧锚点)"]
b2f["add 的局部变量"]
b2g["← %rsp"]
end
block:B3["③ add 返回后"]:1
columns 1
b3a["main 的局部变量"]
b3b["← %rbp(恢复回 main)"]
b3c["← %rsp(帧消失)"]
end
style B1 fill:#1e293b,color:#94a3b8
style B2 fill:#1e293b,color:#94a3b8
style B3 fill:#1e293b,color:#94a3b8
style b2e fill:#1e3a5f,color:#7dd3fc
style b2f fill:#1e3a5f,color:#7dd3fc
style b2g fill:#1e3a5f,color:#7dd3fc
单个栈帧的内部结构
每次函数调用都会在栈上创建一个 栈帧(Stack Frame):
block-beta
columns 1
block:Caller["调用方的栈帧(高地址)"]
A["..."]
end
B["返回地址(call 自动压入)"]
C["旧 %rbp(push %rbp 保存调用方帧基址)"]
block:Frame["当前函数栈帧 ← %rbp 指向这里(帧锚点)"]
D["局部变量 a(rbp - 4)"]
E["局部变量 b(rbp - 8)"]
F["...(向低地址生长)"]
end
G["← %rsp(栈顶,低地址)"]
style Caller fill:#334155,color:#94a3b8
style Frame fill:#1e3a5f,color:#7dd3fc
对应的标准函数序言 / 尾声:
# === 序言 ===
pushq %rbp # 保存调用方的栈帧基址
movq %rsp, %rbp # 当前栈顶成为新栈帧的锚点
subq $16, %rsp # 为局部变量腾空间
# === 函数体 ===
movl $42, -4(%rbp) # 局部变量 a = 42
...
# === 尾声 ===
movq %rbp, %rsp # 释放局部变量
popq %rbp # 恢复调用方栈帧
ret # 弹出返回地址跳回去
leave指令 =movq %rbp, %rsp+popq %rbp,编译器有时会用它简写。
识别栈帧有什么用?
直觉上可能觉得:反编译不就是找 call func 吗,认识栈帧有什么意义?但 call 只告诉你调用关系,栈帧告诉你函数的边界和内部结构——有几类情况 call 直接解决不了:
场景一:间接调用,call 里没有名字
call *%rax # 调用谁?不知道,要往前追 rax 从哪加载的
call *(%rdi, %rcx, 8) # 函数指针表,更复杂
这时需要识别栈帧结构,往前回溯数据流,才能推断出实际调用目标。
场景二:尾调用优化,call 被优化成 jmp
# C 代码:return foo(x);
# 编译器优化后:
jmp foo # 复用当前栈帧,省去建新帧的开销——不是 call,但这是函数调用
只盯着 call 会漏掉这类调用。看到 jmp 跳向别的函数、且当前函数 epilogue 消失,就是尾调用的特征。
场景三:内联函数,根本没有 call
小函数被编译器直接展开进调用方,所有指令都混在一起,没有任何 call。识别局部变量的偏移规律(rbp-N 的密集访问突然换了一批)可以帮你意识到"这一段逻辑原本是独立的函数"。
场景四:切出函数边界
call 告诉你"从哪里进入函数",但不告诉你函数从哪开始、在哪结束。在没有符号表的裸二进制里,靠 prologue(push %rbp; mov %rsp, %rbp)和 epilogue(pop %rbp; ret)才能准确切出函数范围。
场景五:还原局部变量
知道 %rbp 锚点在哪,才能把 rbp-4、rbp-8、rbp-24 分别对应成具体的变量,理解函数在操作什么数据——这是读懂函数逻辑的基础。
call → 调用关系(谁调用了谁)
栈帧 → 函数边界 + 局部变量布局 + 优化后的隐式调用
两者配合,才能完整还原一段汇编的逻辑。
栈帧快照解读:old ebp 和 rtn addr 是什么
当你在汇编里看到 8(%ebp)、4(%ebp) 时,你需要知道栈上那几个"特殊格"的含义。下面这张图是一次函数调用进入被调函数、执行序言之后的栈快照(32 位):
高地址
┌─────────────────────────┐
│ arg2(第 2 个参数) │ ← ebp + 12
├─────────────────────────┤
│ arg1(第 1 个参数) │ ← ebp + 8
├─────────────────────────┤
│ rtn addr(返回地址) │ ← ebp + 4 ★
├─────────────────────────┤
│ old ebp(旧帧指针) │ ← ebp + 0 ★ ← ebp 现在就指向这里
├─────────────────────────┤
│ 局部变量 a │ ← ebp - 4
├─────────────────────────┤
│ 局部变量 b │ ← ebp - 8
└─────────────────────────┘
低地址(← esp 在这附近)
old ebp 是什么?从哪来的?
序言第一条指令 pushl %ebp 把调用方(caller)当时的 %ebp 值压入栈——这就是 old ebp。它的目的是让被调函数(callee)结束时能把 %ebp 恢复回去。
# 进入函数时的序言:
pushl %ebp # ① old ebp 压栈(存调用方的帧基址)
movl %esp, %ebp # ② esp 成为新的 ebp(当前帧锚点建立)
subl $8, %esp # ③ 为局部变量腾空间
执行完 ② 之后,%ebp 就指向自己刚刚压进去的那个格子,即 old ebp 所在的位置——这就是为什么 ebp+0 就是 old ebp,ebp+4 就是返回地址(它在 old ebp 的上面,更早被压进栈的)。
rtn addr(返回地址)是什么?从哪来的?
call func 这条指令由 CPU 自动拆解为两步:
- 把
call之后那条指令的地址(即"如果函数返回了,下一条要执行的指令")压入栈 - 跳转到
func
这个被自动压进栈的地址就是 rtn addr(返回地址)。ret 指令执行时,CPU 从栈顶弹出它,直接跳回去继续执行调用方的代码。
为什么参数从 ebp+8 开始,而不是 ebp+4?
在 call func 执行后、序言执行前,栈的状态是:
← esp 就在这里
┌──────────────────┐
│ rtn addr │ ← esp + 0(call 刚压进来的)
├──────────────────┤
│ arg1 │ ← esp + 4(caller 早一步压进来的)
├──────────────────┤
│ arg2 │ ← esp + 8
└──────────────────┘
序言执行 pushl %ebp 之后,old ebp 插在了 esp 的位置,把所有内容往上推了一格:
┌──────────────────┐
│ old ebp │ ← esp(新压入)= ebp(movq 之后) → ebp + 0
├──────────────────┤
│ rtn addr │ → ebp + 4
├──────────────────┤
│ arg1 │ → ebp + 8
├──────────────────┤
│ arg2 │ → ebp + 12
└──────────────────┘
中间隔了一个 old ebp(4 字节)和一个 rtn addr(4 字节),所以第一个参数永远从 ebp+8 开始,第二个参数 ebp+12,依此类推,每个参数间隔 4 字节。
函数返回时(epilogue)发生了什么
movl %ebp, %esp # ① esp 回退到 ebp 位置(丢弃局部变量)
popl %ebp # ② 弹出 old ebp,%ebp 恢复成调用方的帧基址
ret # ③ 弹出 rtn addr,PC 跳回调用方
leave指令 = ① + ②,是这两步的简写。
一句话速查
| 偏移 | 是什么 | 来源 |
|---|---|---|
ebp + 0 | old ebp | pushl %ebp(序言压入) |
ebp + 4 | rtn addr | call func(CPU 自动压入) |
ebp + 8 | 第 1 个参数 | caller 调用前压栈 |
ebp + 12 | 第 2 个参数 | caller 调用前压栈 |
ebp - 4 | 第 1 个局部变量 | subl $N, %esp 分配 |
ebp - 8 | 第 2 个局部变量 | 同上 |
记忆口诀:
old ebp和rtn addr是两个"门卫",占据ebp+0和ebp+4,把参数推到ebp+8以上。
实战:从汇编还原内联函数调用
以下是一段没有符号表、没有源码的 32 位汇编,目标是还原出原始 C 代码。
sum_of_squares:
pushl %ebp
movl %esp, %ebp
subl $4, %esp
movl 8(%ebp), %eax
imull 8(%ebp), %eax
movl %eax, -4(%ebp)
movl 12(%ebp), %eax
imull 12(%ebp), %eax
addl -4(%ebp), %eax
leave
ret
第一步:识别函数边界
pushl %ebp / movl %esp, %ebp 是 prologue,leave / ret 是 epilogue——这是一个完整函数,名字是 sum_of_squares。
第二步:建立 ebp 偏移表
32 位参数调用前已压栈,在 正偏移;局部变量由 subl 分配,在 负偏移:
| ebp 偏移 | 来源 | 含义 |
|---|---|---|
ebp+8 | call 前压栈(第 1 参数) | 变量 a |
ebp+12 | call 前压栈(第 2 参数) | 变量 b |
ebp-4 | subl $4, %esp 分配 | 临时变量 tmp |
32 位读参数用正偏移(
ebp+N),局部变量用负偏移(ebp-N)——正负分界是快速区分两者的方法。
第三步:逐段翻译
movl 8(%ebp), %eax # eax = a
imull 8(%ebp), %eax # eax = a * a ← 第一段
movl %eax, -4(%ebp) # tmp = a * a
movl 12(%ebp), %eax # eax = b
imull 12(%ebp), %eax # eax = b * b ← 第二段(结构完全相同)
addl -4(%ebp), %eax # eax = b*b + tmp = a*a + b*b
第四步:发现重复模式 → 推断内联
两段结构完全相同:读参数 → imull 自乘 → 结果留在 eax。同一个模式出现两次,高度怀疑原来有一个 square(x) 函数被内联展开:
flowchart LR
A["square(a)\n读 ebp+8 → a×a → tmp"] --> C["tmp + square(b)"]
B["square(b)\n读 ebp+12 → b×b → eax"] --> C
C --> D["return eax"]
style A fill:#1e3a5f,color:#7dd3fc
style B fill:#1e3a5f,color:#7dd3fc
style C fill:#334155,color:#94a3b8
第五步:还原 C 代码
// 被内联的小函数(编译器展开后 call 消失)
static inline int square(int x) {
return x * x;
}
// 实际编译的函数
int sum_of_squares(int a, int b) {
return square(a) + square(b);
}
分析套路总结
碰到看不懂的汇编,按这个顺序走:
- 定边界:找 prologue / epilogue,确认函数范围
- 建偏移表:正偏移 → 参数;负偏移 → 局部变量;全部映射成有意义的变量名
- 逐段翻译:每条指令写成伪代码注释
- 找重复:相同结构出现多次 → 原来可能是同一个被内联的函数
- 还原:把重复模式提取成函数,反推原始 C 代码
64 位的区别:参数通过寄存器(
%edi, %esi...)传入,编译器再存到ebp-N,所以参数和局部变量都在负偏移,正负分界法失效。本课程考试用 32 位,记住正偏移=参数即可。
32位 vs 64位偏移逻辑对比
经典帧指针模型下,规则两者相同
只要编译器保留了帧指针(ebp/rbp),正负偏移的含义在32位和64位里是一致的:
| 偏移方向 | 访问的是 | 原因 |
|---|---|---|
正偏移 ebp/rbp + N | 函数参数 | 调用方压参数 → 压返回地址 → 压旧 ebp,参数留在更高地址 |
负偏移 ebp/rbp - N | 局部变量 | 帧指针建立后向下分配,局部变量在更低地址 |
为什么32位满眼正偏移、64位满眼负偏移?
规则没变,是参数传递方式变了:
- 32位(cdecl/stdcall):参数全部压栈传递 → 大量
[ebp+8]、[ebp+12],正偏移显眼 - 64位(System V AMD64 ABI):前 6 个参数用寄存器传,几乎不压栈 →
[rbp+N]极少出现,正偏移"消失了"
64位负偏移多的三个原因
-
寄存器传参:参数不在栈上,正偏移本来就少
-
红区(Red Zone):System V AMD64 ABI 规定
rsp以下 128 字节为保留区域。叶子函数(不调用其他函数)可以直接用[rsp-8]、[rsp-16]存局部变量,不需要先执行sub rsp, N腾空间。注意:红区是以rsp为基准,不是rbp。# 叶子函数里可能看到(没有 sub rsp,直接用红区): movl %edi, -4(%rsp) # 直接往 rsp 下方写,不调整栈顶 movl %esi, -8(%rsp) -
省略帧指针优化(
-fomit-frame-pointer):64位编译器开-O2后默认不建立rbp帧,rbp当普通寄存器用,全程用rsp寻址。这时连rbp都没有,正偏移更不可能出现。
考试只用32位,记这个就够
正偏移 [ebp+8], [ebp+12]... → 参数(从 +8 开始,每个参数 +4)
负偏移 [ebp-4], [ebp-8]... → 局部变量
红区和省略帧指针是实际逆向的知识,考试不涉及。
3.2 call 与 ret 的内存操作
等价展开(32位)
call func
# CPU 内部做了两件事:
pushl <call下一条指令的地址> # ① 返回地址压栈:esp -= 4,写入栈顶
jmp func # ② PC 跳到 func
ret
# CPU 内部做了一件事:
popl %eip # 弹出返回地址:eip = *esp,esp += 4
pushl %eip/popl %eip不能直接写在汇编里——这是 CPU 内部行为,只是用来理解原理。
栈上发生了什么
block-beta
columns 3
block:S1["call 之前"]:1
columns 1
s1a["main 的局部变量"]
s1b["← %ebp"]
s1c["← %esp"]
end
block:S2["call func 执行后"]:1
columns 1
s2a["main 的局部变量"]
s2b["← %ebp(未变)"]
s2c["返回地址(call 后那条指令)"]
s2d["← %esp(自动 -4)"]
end
block:S3["ret 执行后"]:1
columns 1
s3a["main 的局部变量"]
s3b["← %ebp"]
s3c["← %esp(自动 +4,回到原位)"]
end
style S1 fill:#1e293b,color:#94a3b8
style S2 fill:#1e293b,color:#94a3b8
style S3 fill:#1e293b,color:#94a3b8
style s2c fill:#1e3a5f,color:#7dd3fc
style s2d fill:#1e3a5f,color:#7dd3fc
考试记忆锚点
容易混的点就两个,记住这张表:
call | ret | |
|---|---|---|
| 做几件事 | 两件:压返回地址 + 跳转 | 一件:弹返回地址(自动跳过去) |
| esp 变化 | -4(压栈) | +4(弹栈) |
| eip 变化 | 跳到 func 首地址 | 跳到返回地址(call 后那条指令) |
| 谁操作 | CPU 自动,不用手写 | CPU 自动,不用手写 |
一句话记住:call 是"留条子再出发",ret 是"拿条子回家"。条子 = 返回地址,压在栈顶。
函数调用的实质 = 栈上存一个返回地址。返回地址被覆盖 = 程序跳去错误的地方(栈溢出攻击的原理)。
3.3 内存访问模式总结
| 场景 | 汇编写法 | 在干什么 |
|---|---|---|
| 读局部变量 | movl -4(%rbp), %eax | 从栈帧取变量值 |
| 写局部变量 | movl %eax, -8(%rbp) | 把结果存回栈帧 |
| 读全局变量 | movl var(%rip), %eax | RIP 相对寻址取全局数据 |
| 数组取值 | movl (%rdi,%rsi,4), %eax | 首地址 + 下标 × 元素大小 |
| 结构体字段 | movl 4(%rdi), %eax | 基地址 + 固定偏移 |
| 指针解引用 | movl (%rdi), %eax | 去 rdi 存的地址取值 |
| 压栈 | pushq %rax | rsp -= 8,数据写入栈顶 |
| 弹栈 | popq %rax | 读栈顶数据,rsp += 8 |
3.4 寄存器的身份分工(System V AMD64 ABI)
| 寄存器 | 身份 | 谁负责保存 |
|---|---|---|
%rax | 返回值 | 调用方读取 |
%rdi, %rsi, %rdx, %rcx, %r8, %r9 | 第 1~6 参数 | caller-saved(用完即弃) |
%r10, %r11 | 临时 | caller-saved |
%rbx, %r12~%r15 | 通用 | callee-saved(被调方必须保存恢复) |
%rbp | 栈帧基址 | callee-saved |
%rsp | 栈顶指针 | 必须保持平衡 |
- caller-saved:调用别人前,自己先存好还要用的值
- callee-saved:被调函数如果动了,必须 push/pop 恢复
四、链接:把零件拼成可执行文件
链接(Linking)交互式图解:从源代码到可执行文件的全过程
4.1 为什么需要链接
编译器一次只处理 一个 .c 文件,生成一个 .o 目标文件。但一个程序通常有很多 .c 和库函数。链接器(ld) 的工作就是把所有 .o 和库文件合并,解析函数名/变量名之间的引用关系,生成最终可执行文件。
flowchart LR
A["main.c"] --> |"gcc -c"| B["main.o"]
C["utils.c"] --> |"gcc -c"| D["utils.o"]
E["libc.a / libc.so"] --> F
B --> F["链接器 ld"]
D --> F
F --> G["a.out(可执行文件)"]
链接时做的两件核心事:
- 符号解析:
main.o里call add写的是占位地址 → 链接器找到add在utils.o里的真实地址,填进去 - 重定位:所有
.o的地址都从 0 开始 → 链接器给每个段分配最终的虚拟地址,修正所有引用
4.2 静态链接 vs 动态链接
| 静态链接 | 动态链接 | |
|---|---|---|
| 时机 | 编译时把库代码 复制进 可执行文件 | 运行时才从共享库 加载 |
| 库文件 | .a(归档文件,多个 .o 打包) | .so(Linux)/ .dll(Windows) |
| 可执行文件大小 | 大(库代码全部嵌入) | 小(只存引用信息) |
| 更新库 | 必须重新编译整个程序 | 替换 .so 文件即可,程序不用动 |
| 运行依赖 | 无(自包含,拷到哪里都能跑) | 需要目标机器上有对应的 .so |
| 内存效率 | 差(每个程序各自一份库代码) | 好(多个程序共享同一份 .so 在内存中的映射) |
flowchart TD
subgraph 静态链接
direction TB
SA["main.o"] --> SB["链接器"]
SC["libmath.a"] --> SB
SB --> SD["a.out(内含 libmath 代码)"]
end
subgraph 动态链接
direction TB
DA["main.o"] --> DB["链接器"]
DB --> DC["a.out(只含引用信息)"]
DC --> |"运行时加载"| DD["libmath.so(共享库)"]
end
动态链接的实际过程
sequenceDiagram
participant OS as 操作系统
participant LD as 动态链接器 ld-linux.so
participant APP as 程序 a.out
participant LIB as 共享库 libc.so
OS->>APP: 加载 a.out 到内存
OS->>LD: 启动动态链接器
LD->>LD: 读取 a.out 的 .dynamic 段,找到依赖的 .so
LD->>LIB: 加载 libc.so 到内存(或复用已加载的)
LD->>APP: 修正 GOT/PLT 表中的函数地址
LD->>APP: 跳转到 main() 开始执行
GOT(Global Offset Table) 和 PLT(Procedure Linkage Table) 是动态链接的核心数据结构——GOT 存"这个函数到底在内存哪",PLT 是一段跳板代码负责第一次调用时触发链接器去填 GOT。
4.3 地址是什么时候确定的?
| 阶段 | 地址状态 | 例子 |
|---|---|---|
编译(.o) | 从 0 开始的 占位地址 | call 0x0(add 的地址还不知道) |
| 静态链接 | 分配 虚拟地址 但写死在文件里 | call 0x401234(链接时确定) |
| 动态链接 | 代码中存的是 PLT 跳板 | call add@PLT(运行时才填真实地址) |
| 加载运行 | OS 映射到 真实虚拟地址(可能被 ASLR 随机化) | 实际地址每次运行可能不同 |
ASLR(地址空间布局随机化) 是安全机制:每次运行程序,栈、堆、共享库的起始地址都随机。攻击者不能硬编码攻击地址。代码通过 RIP 相对寻址 + PIC(位置无关代码) 来兼容随机化。
4.4 符号解析:call 指令的"占位地址"
两个文件分别编译,main.c 调用 add.c 里的函数:
// add.c
int add(int a, int b) { return a + b; }
// main.c
int add(int a, int b);
int main() { return add(3, 4); }
未链接时,反汇编 main.o(objdump -d main.o):
0000000000000000 <main>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: be 04 00 00 00 mov $0x4,%esi # 参数 b=4
9: bf 03 00 00 00 mov $0x3,%edi # 参数 a=3
e: e8 00 00 00 00 call 13 <main+0x13> ← 全是 0,占位
13: 5d pop %rbp
14: c3 ret
链接后,反汇编 a.out:
0000000000401136 <main>:
401136: 55 push %rbp
...
401144: e8 d7 ff ff ff call 401120 <add> ← 已填入真实偏移
401149: 5d pop %rbp
40114a: c3 ret
call 使用 PC 相对偏移(rel32),不是绝对地址:
rel32 = 目标地址 − call 指令结束地址
= 0x401120 − 0x401149
= −0x29 = 0xffffffd7 ✓
CPU 执行 call 时,%rip 已经指向下一条指令(0x401149),加上 rel32 就跳到 add。好处是:代码段整体移动时,只要两函数相对距离不变,这条指令就不用改。
4.5 重定位条目:链接器的 TODO 清单
汇编器看到 call add 时 add 的地址未知,就在 .rela.text 里留一条重定位条目:
readelf -r main.o
Relocation section '.rela.text':
Offset Type Sym. Name + Addend
0x0f R_X86_64_PLT32 add - 4
| 字段 | 含义 | 本例 |
|---|---|---|
| Offset | 需要修改的字节位置(相对 .text 起始) | 0x0f(e8 后面的 4 字节) |
| Type | 计算公式类型 | R_X86_64_PLT32:PC 相对 |
| Sym. Name | 引用的符号 | add |
| Addend | 偏移修正量 | −4(补偿 rel32 字段本身的宽度) |
链接器按公式计算填入值:
填入值 = 符号地址(S) + 加数(A) − 修改位置的运行时地址(P)
= 0x401120 + (−4) − 0x401145
= 0xffffffd7 (即 −41)
把这 4 字节写进 e8 后面,占位符就被填满,目标文件变成可执行文件。
4.6 PLT / GOT:动态调用的汇编跳板
调用动态库函数(如 printf)时,汇编里是 call printf@plt。反汇编 PLT 段:
objdump -d a.out | grep -A6 '<printf@plt>'
0000000000401020 <printf@plt>:
401020: ff 25 ea 2f 00 00 jmp *0x2fea(%rip) # 跳到 GOT 中存的地址
401026: 68 00 00 00 00 push $0x0 # PLT 索引
40102b: e9 e0 ff ff ff jmp 401010 <.plt> # 跳解析器入口
三行指令形成一个跳板:第一行查 GOT,GOT 里若已填真实地址就直接跳过去;若尚未解析则跌落到 push + jmp,触发动态链接器解析。
懒绑定(Lazy Binding)流程:
sequenceDiagram
participant C as 代码 call printf@plt
participant PLT as PLT 跳板
participant GOT as GOT[printf]
participant LD as 动态链接器
note over GOT: 初始值 = PLT+6(push 那行地址)
C->>PLT: 第一次调用
PLT->>GOT: jmp *GOT[printf]
GOT-->>PLT: 尚未解析,跳回 PLT+6
PLT->>LD: push 索引;jmp 解析器
LD->>GOT: 找到 printf 真实地址,写入 GOT
LD->>C: 跳转执行 printf
note over C: 第二次及之后
C->>PLT: call printf@plt
PLT->>GOT: jmp *GOT[printf]
GOT->>C: GOT 已填,直接跳 printf ✓
为什么这样设计:
- 懒绑定:启动时只解析实际调用到的符号,大型程序导入几百个符号时启动更快
- W^X 安全:GOT 在可写数据段,PLT 在只读代码段,代码段运行时不需要修改
- 函数插桩:
LD_PRELOAD可以替换 GOT 中的地址,实现透明的函数拦截(调试、Mock、安全审计)
GOT 覆写是经典漏洞:若程序存在任意写漏洞,攻击者可篡改 GOT 中
printf的地址为system,下次printf(user_input)就变成了system(user_input)。现代系统用 RELRO(将 GOT 标记为只读)来防御。
4.7 位置无关代码(PIC)
共享库被多个进程以不同基地址加载,代码里不能有绝对地址——必须编译为 PIC(Position Independent Code)。
extern int g;
int get_g() { return g; }
不加 -fPIC(位置相关):
get_g:
mov g(%rip), %eax # RIP 相对,地址在链接时写死——不能做共享库
ret
加 -fPIC(位置无关):
get_g:
mov g@GOTPCREL(%rip), %rax # rax = GOT 里存 g 地址的那个槽
mov (%rax), %eax # eax = g 的值(通过 GOT 间接)
ret
位置相关(-fno-PIC) | 位置无关(-fPIC) | |
|---|---|---|
| 寻址方式 | 绝对地址 / 固定偏移写死 | 通过 GOT 间接寻址 |
| 能做共享库 | 否(需重定位整个代码段) | 是(只需重定位 GOT) |
| 性能 | 略快(少一次内存间接) | 极小开销(通常可忽略) |
| 适用场景 | 普通主程序 | 共享库 .so、开启 PIE 的主程序 |
为什么 .so 必须是 PIC:.text 代码段在多进程间物理共享(同一块物理内存页被多个进程的页表同时映射)。若代码里含绝对地址,每个进程要单独一份——失去共享库的意义。GOT 每个进程有自己的副本(数据段),代码段完全不变,才能真正共享。
4.8 链接器的输入与输出文件
链接器本质上是一个"文件合并 + 地址修正器",理解它的 I/O 文件格式是掌握链接机制的前提。
输入文件
flowchart TD
A["可重定位目标文件 .o\n(Relocatable Object File)\nELF 类型:ET_REL"]
B["静态库 .a\n(Archive / Static Library)\n多个 .o 打包的归档"]
C["共享目标文件 .so\n(Shared Object / Dynamic Library)\nELF 类型:ET_DYN"]
D["链接器脚本 .ld\n(Linker Script)\n控制段的地址和布局"]
E["链接器 ld"]
A --> E
B --> E
C --> E
D --> E
| 输入类型 | 扩展名 | ELF 类型 | 说明 |
|---|---|---|---|
| 可重定位目标文件 | .o | ET_REL | 汇编器的输出,含机器码和重定位条目,地址全为 0 占位 |
| 静态库 | .a | 归档(非 ELF) | ar 命令把多个 .o 打包,链接器按需提取其中被引用的 .o |
| 共享目标文件 | .so | ET_DYN | 动态库,链接时只记录引用,运行时才加载 |
| 链接器脚本 | .ld | 文本文件 | 指定段的起始地址、内存布局、符号定义(嵌入式开发常用) |
静态库(.a)的工作方式:
# 打包:把多个 .o 归档成 .a
ar rcs libmath.a add.o mul.o sub.o
# 链接时按需提取:只有 add.o 被引用,才会被提取链接进来
gcc main.o -L. -lmath -o a.out
链接器从左到右扫描命令行,遇到 .a 时只提取 解决了当前未定义引用 的 .o——其余的 .o 被忽略。这就是为什么 -l 选项的顺序有时候会影响链接结果(被依赖的库要放后面)。
输出文件
| 输出类型 | ELF 类型 | 如何生成 | 特点 |
|---|---|---|---|
| 可执行文件 | ET_EXEC | gcc main.o -o a.out | 有入口地址,含程序头表,OS 可直接加载执行 |
| 共享目标文件 | ET_DYN | gcc -shared -fPIC -o libfoo.so foo.o | 可被多进程共享映射,含动态符号表 |
| 可重定位目标文件 | ET_REL | ld -r a.o b.o -o ab.o(部分链接) | 仍含重定位信息,可继续参与后续链接 |
flowchart LR
subgraph 输入
O1["main.o (ET_REL)"]
O2["utils.o (ET_REL)"]
LA["libc.a (归档)"]
SO["libpthread.so (ET_DYN)"]
end
LD["链接器 ld"]
subgraph 输出
EX["a.out (ET_EXEC)\n可直接执行"]
end
O1 --> LD
O2 --> LD
LA --> |"按需提取 .o"| LD
SO --> |"记录依赖"| LD
LD --> EX
4.9 各编程语言的链接栈
各编程语言链接栈对比:从包管理器到底层链接器的完整路径
五、虚拟内存:程序看到的地址是"假"的
5.1 虚拟地址 vs 物理地址
程序中所有地址——%rip 指向的指令地址、%rsp 指向的栈地址、malloc 返回的堆地址——都是 虚拟地址,不是 RAM 上的真实物理地址。
flowchart LR
subgraph 程序看到的
VA["虚拟地址 0x7fff0010"]
end
subgraph CPU 硬件
MMU["MMU(内存管理单元)"]
end
subgraph 实际 RAM
PA["物理地址 0x2A0010"]
end
VA --> MMU --> PA
每个进程拥有自己 独立的虚拟地址空间。进程 A 的
0x7fff0010和进程 B 的0x7fff0010映射到完全不同的物理内存——互相看不见,互相踩不到。
5.2 页表:虚拟地址到物理地址的映射
虚拟地址空间被切成固定大小的 页(Page)(通常 4KB = 4096 字节),物理内存也被切成相同大小的 页帧(Page Frame)。OS 维护一张 页表(Page Table) 记录映射关系:
flowchart LR
subgraph 虚拟地址空间
VP0["虚拟页 0"]
VP1["虚拟页 1"]
VP2["虚拟页 2"]
VP3["虚拟页 3"]
VP4["虚拟页 4"]
end
subgraph 页表
PT["页表条目 PTE"]
end
subgraph 物理内存
PF0["页帧 5"]
PF1["页帧 12"]
PF2["页帧 3"]
end
subgraph 磁盘 Swap
SW["换出的页"]
end
VP0 --> PT --> PF0
VP1 --> PT --> PF1
VP3 --> PT --> PF2
VP2 --> PT --> SW
VP4 -.- |"未分配"| PT
虚拟页的三种状态:
- 已映射,在物理内存:正常访问,MMU 直接翻译
- 已映射,被换出到磁盘(Swap):访问时触发 缺页异常(Page Fault),OS 从磁盘读回
- 未分配:访问时触发 段错误(Segmentation Fault),程序崩溃
5.2.1 虚拟地址是怎么被拆开的
页大小是 4KB = 2¹² 字节,所以一个虚拟地址的 低 12 位是页内偏移(offset),高位部分是 虚拟页号(VPN)。翻译只需要换掉「页号」,偏移原样保留:
flowchart LR
subgraph VA["虚拟地址 48 位"]
VPN["虚拟页号 VPN(高 36 位)"]
OFF1["页内偏移(低 12 位)"]
end
subgraph PA["物理地址"]
PFN["物理页帧号 PFN"]
OFF2["页内偏移(原样照搬)"]
end
VPN -->|"查页表"| PFN
OFF1 -->|"不变"| OFF2
关键直觉:翻译的粒度是「页」不是「字节」。同一页内的 4096 个字节共享一条映射,所以页表项的数量 = 页的数量,而不是字节的数量。
5.2.2 为什么是「多级」页表
x86-64 实际用 48 位虚拟地址。如果用 单级页表:页号有 36 位 → 2³⁶ 个页表项 → 每项 8 字节 → 一张页表就要 512 GB。而且每个进程都要有自己的一张。这显然不可能。
问题的本质是:进程的地址空间是 极度稀疏的——48 位空间有 256 TB,但一个进程实际只用到代码、堆、栈那几小块。单级页表却要为「每一个可能的页」都留一个槽位,绝大多数是空的。
多级页表 用「树」解决这个问题:把页号再切成几段,逐级索引。没有用到的子树根本不分配——一整片空洞地址只对应顶层的一个空指针。
x86-64 用 四级页表:
flowchart LR
CR3["CR3 寄存器<br/>(指向顶级页表)"]
CR3 --> L4["PML4<br/>第 4 级"]
L4 --> L3["PDPT<br/>第 3 级"]
L3 --> L2["PD<br/>第 2 级"]
L2 --> L1["PT<br/>第 1 级"]
L1 --> FRAME["物理页帧"]
48 位页号被切成 4 段、每段 9 位(2⁹ = 512 项,正好一页放得下一张子表),最后 12 位是页内偏移:9 + 9 + 9 + 9 + 12 = 48。每个进程切换时,OS 把它的顶级页表地址装进 CR3 寄存器——换 CR3 就等于换了一整套地址映射,这正是进程隔离的硬件基础。
5.2.3 页表项(PTE)里装了什么
一个 PTE 是 64 位,除了 物理页帧号,还有一组关键的 标志位。MMU 每次翻译都会读它们:
| 标志位 | 含义 | 用途 |
|---|---|---|
| P(Present) | 该页是否在物理内存 | =0 时访问触发缺页异常 |
| R/W | 可读 / 可读写 | 写只读页 → 保护异常 |
| U/S(User/Supervisor) | 用户态能否访问 | 用户进程碰内核页 → 异常 |
| A(Accessed) | 被访问过 | 页面置换算法判断「冷热」 |
| D(Dirty) | 被写过(脏页) | 换出时决定要不要写回 |
| NX(No-eXecute) | 禁止执行 | 数据页不可当代码跑 |
这张标志位表是后面 §5.6(内存保护)和 §5.7(Swap 选择冷页)的共同基础——页表不只是「地址翻译表」,更是 OS 给每一页贴的「权限标签 + 状态记录」。
5.3 地址翻译的完整流程:TLB 与缺页
四级页表有个直接的代价:每访问一次内存,要先额外读 4 次内存 去走页表。这等于把内存速度直接砍到 1/5,无法接受。
解决办法是 TLB(Translation Lookaside Buffer,快表)——MMU 内部的一小块高速缓存,专门缓存最近用过的「虚拟页号 → 物理页帧」映射。它就是「页表的 Cache」。
一次完整的地址翻译:
flowchart TD
START["CPU 发出虚拟地址"] --> TLB{"TLB 命中?"}
TLB -->|"命中(绝大多数)"| FAST["直接得到物理页帧<br/>→ 拼上偏移完成访问"]
TLB -->|"未命中"| WALK["MMU 遍历四级页表"]
WALK --> PRESENT{"PTE 的 P 位 = 1?"}
PRESENT -->|"是"| FILL["把映射填进 TLB<br/>→ 完成访问"]
PRESENT -->|"否"| FAULT["触发缺页异常 Page Fault<br/>→ 交给 OS 处理"]
FAULT --> OS{"OS 判断这个地址"}
OS -->|"合法但没在内存<br/>(被换出 / 没分配物理页)"| HANDLE["分配页帧 / 从 Swap 读回<br/>→ 更新页表 → 重新执行指令"]
OS -->|"非法(越界 / 权限不符)"| SEGV["发送 SIGSEGV<br/>→ 段错误,进程崩溃"]
几个要点:
- TLB 命中率通常 > 99%——靠的还是局部性原理,程序短时间内反复访问的就那几页。
- 进程切换时 TLB 要失效:换了 CR3,旧映射就作废了。现代 CPU 用 PCID/ASID(地址空间标识)给 TLB 项打标签,避免每次切换都全部清空。
- 缺页异常不一定是错误:它是虚拟内存「按需工作」的正常机制——下面 §5.5、§5.7 讲的按需调页、Swap 读回,全都靠缺页异常这个入口触发。真正的错误(段错误)只是其中一种分支。
5.4 进程的虚拟地址空间布局
每个进程看到的虚拟地址空间长这样(以 Linux x86-64 为例):
block-beta
columns 1
A["内核空间(用户不可访问)"]
space
B["栈 Stack ↓ ← %rsp"]
space
C["↑ 共享库 .so(mmap 区域)"]
space
D["↑ 堆 Heap(malloc / new)"]
E[".bss — 未初始化全局变量"]
F[".data — 已初始化全局变量"]
G[".rodata — 只读数据(字符串常量等)"]
H[".text — 代码段(机器码)← %rip 从这取指"]
style A fill:#4a1942,color:#e8a0d0
style B fill:#1e3a5f,color:#7dd3fc
style C fill:#3b4252,color:#d8dee9
style D fill:#2e4057,color:#88c0d0
style E fill:#334155,color:#94a3b8
style F fill:#334155,color:#94a3b8
style G fill:#334155,color:#94a3b8
style H fill:#1a472a,color:#a3d9a5
栈从高地址 向下 生长,堆从低地址 向上 生长,中间是共享库映射区。OS 通过页表为每段设置不同权限:
.text只读+可执行,.data可读写+不可执行,栈可读写+不可执行(NX bit 防御)。
5.4 Swap:当物理内存不够用
物理 RAM 是有限的。当进程用掉的页超过物理内存容量时,OS 会把 最近不用的页 写到磁盘的 交换空间(Swap),腾出物理页帧给急需的进程。等被换出的页再次被访问时,OS 再把它从磁盘读回来。
sequenceDiagram
participant APP as 程序
participant MMU as MMU
participant OS as 操作系统
participant RAM as 物理内存
participant DISK as 磁盘 Swap
APP->>MMU: 访问虚拟地址 0x7fff0010
MMU->>MMU: 查页表:该页不在物理内存!
MMU->>OS: 触发缺页异常 Page Fault
OS->>RAM: 物理内存满了,选一个"冷页"换出
OS->>DISK: 冷页写入 Swap
OS->>DISK: 从 Swap 读回需要的页
OS->>RAM: 放入空出的页帧
OS->>MMU: 更新页表映射
MMU->>APP: 重新执行刚才的访问指令,成功
Swap 的代价是巨大的——磁盘比内存慢 10 万倍以上。如果系统频繁 Swap(称为 thrashing / 抖动),性能会断崖式下降。这就是为什么内存不够时电脑会卡成幻灯片。
5.5 虚拟内存的好处总结
| 好处 | 怎么实现的 |
|---|---|
| 进程隔离 | 每个进程有独立页表,互相看不见内存 |
| 安全保护 | 页表条目带权限位(读/写/执行),越权触发异常 |
| 内存超售 | 虚拟空间可以比物理内存大(靠 Swap 兜底) |
| 共享库 | 多进程的页表可以映射到同一物理页帧(共享 .so) |
| 按需加载 | 程序启动时不需要把所有代码/数据都加载到 RAM |
| ASLR | 每次映射地址随机化,增加攻击难度 |
六、目标代码与机器码:汇编变成二进制是什么
6.1 汇编 → 机器码的对应关系
汇编指令是助记符,汇编器(as)把它翻译成 CPU 直接执行的 二进制机器码:
汇编指令 机器码(十六进制) 二进制
─────────────────────────────────────────────────────────
movl $1, %eax B8 01 00 00 00 10111000 00000001 ...
addl $5, %eax 83 C0 05 10000011 11000000 00000101
ret C3 11000011
pushq %rbp 55 01010101
movq %rsp, %rbp 48 89 E5 01001000 10001001 11100101
几件重要的事:
- x86 指令变长(1~15字节)。
ret只要 1 字节,movl $1, %eax需要 5 字节 - 立即数按小端序存储:
$1编码为01 00 00 00 - 寄存器用编号编入指令:
B8= mov 到 eax,B9= mov 到 ecx(只差寄存器编号位)
6.2 用 objdump 看真实的机器码
gcc -c -O0 example.c -o example.o
objdump -d example.o
0000000000000000 <add>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 89 7d fc mov %edi,-0x4(%rbp)
7: 89 75 f8 mov %esi,-0x8(%rbp)
a: 8b 45 fc mov -0x4(%rbp),%eax
d: 03 45 f8 add -0x8(%rbp),%eax
10: 5d pop %rbp
11: c3 ret
偏移地址从 0 开始——这是
.o文件(未链接),链接后会变成最终虚拟地址。
6.3 指令编码结构
x86 指令的编码格式(理解即可):
flowchart LR
A["前缀(可选)"] --> B["REX(64位可选)"] --> C["操作码(1~3B)"] --> D["ModR/M(可选)"] --> E["SIB(可选)"] --> F["位移量(可选)"] --> G["立即数(可选)"]
style C fill:#2e8b57,color:#fff
style D fill:#4682b4,color:#fff
拆解 movl %edi, -0x4(%rbp) → 89 7d fc:
| 字节 | 含义 |
|---|---|
89 | 操作码:mov r/m32, r32 |
7d | ModR/M:edi 为源,[rbp + disp8] 为目标 |
fc | 位移量 -4(0xfc = -4 的补码) |
6.4 从源码到可执行文件的完整链路
flowchart LR
A["hello.c"] -->|"gcc -E(预处理)"| B[".i 展开宏"]
B -->|"gcc -S(编译)"| C[".s 汇编文本"]
C -->|"as(汇编)"| D[".o 目标文件"]
D -->|"ld(链接)"| E["a.out 可执行文件"]
gcc -E hello.c -o hello.i # 预处理:展开 #include、#define
gcc -S hello.c -o hello.s # 编译:C → 汇编
gcc -c hello.c -o hello.o # 汇编:汇编 → 目标文件
gcc hello.c -o hello # 一步到位
objdump -d hello.o # 反汇编 .text 段
objdump -s -j .rodata hello.o # 看只读数据段
readelf -h hello.o # 看 ELF 头信息
6.5 ELF 格式详解
ELF(Executable and Linkable Format)是 Linux/Unix 上目标文件、可执行文件和共享库的统一格式。三种 ELF 文件类型共用同一个头部结构,但内部组成有所不同。
ELF 文件的三种类型
| 类型 | e_type | 典型文件 | 说明 |
|---|---|---|---|
| 可重定位 | ET_REL | foo.o | 汇编器输出,地址 0 起,含重定位条目 |
| 可执行 | ET_EXEC | a.out | 链接器输出,有固定入口,OS 直接加载 |
| 共享对象 | ET_DYN | libfoo.so | PIC 代码,运行时被加载器映射 |
ELF 文件整体布局
block-beta
columns 1
A["ELF Header(64字节)\n魔数 / 文件类型 / 架构 / 入口地址 / 段表/节表偏移"]
B["Program Header Table(可选)\n描述「段(Segment)」→ 给加载器/OS 用\n.o 文件没有;可执行文件和 .so 必须有"]
C["节区(Sections)\n.text .data .bss .rodata\n.symtab .strtab .rel.text .got .plt ..."]
D["Section Header Table(可选)\n描述每个节的名称、类型、偏移、大小\n链接器用;strip 后可删除"]
style A fill:#4a1942,color:#e8a0d0
style B fill:#1e3a5f,color:#7dd3fc
style C fill:#1a472a,color:#a3d9a5
style D fill:#2e4057,color:#88c0d0
核心区别:节(Section) 是链接器视角的逻辑划分;段(Segment) 是加载器视角的内存映射单位。多个相邻节可以合并进同一个段。
ELF Header 关键字段
readelf -h a.out
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 ... ← 魔数(0x7f + "ELF")
Class: ELF64 ← 32位/64位
Data: 2's complement, little endian
Type: EXEC (Executable file) ← ET_EXEC
Machine: Advanced Micro Devices X86-64
Entry point address: 0x401050 ← main 的虚拟地址(_start 入口)
Start of program headers: 64 (bytes into file)
Start of section headers: 14312 (bytes into file)
Number of program headers: 13
Number of section headers: 31
| 字段 | 含义 |
|---|---|
Magic | 7f 45 4c 46("ELF");第 5 字节 01=32位 02=64位 |
Type | ET_REL=1 / ET_EXEC=2 / ET_DYN=3 |
Entry point | 程序执行起点(.o 中为 0,可执行文件中为 _start 地址) |
phoff / shoff | 程序头表 / 节头表在文件中的字节偏移 |
phnum / shnum | 程序头 / 节头的条目数量 |
节(Section)详解
readelf -S a.out # 查看节头表
| 节名 | 类型 | 权限 | 内容 | 备注 |
|---|---|---|---|---|
.text | PROGBITS | r-x | 机器码指令 | 只读可执行,多进程共享 |
.rodata | PROGBITS | r-- | 字符串常量、跳转表 | 只读,不可执行 |
.data | PROGBITS | rw- | 已初始化全局/静态变量 | 运行时可写 |
.bss | NOBITS | rw- | 未初始化全局/静态变量 | 占文件 0 字节,加载时由 OS 清零 |
.symtab | SYMTAB | — | 符号表(函数名、变量名 → 地址) | strip 后可删除 |
.dynsym | DYNSYM | — | 动态符号表(运行时链接需要) | strip 后不能删除 |
.strtab | STRTAB | — | 符号名字符串(.symtab 用) | 存符号的实际名字 |
.dynstr | STRTAB | — | 动态符号名字符串(.dynsym 用) | 运行时动态链接器读取 |
.rel.text / .rela.text | RELA | — | 重定位条目(.o 中) | 指示链接器在哪里填地址 |
.got | PROGBITS | rw- | 全局偏移表 | 存外部变量/函数的运行时地址 |
.got.plt | PROGBITS | rw- | PLT 用的 GOT 槽 | 懒绑定时被动态链接器填写 |
.plt | PROGBITS | r-x | PLT 跳板代码 | 每个动态符号一条 |
.dynamic | DYNAMIC | rw- | 动态链接元信息 | 依赖的 .so 列表、初始化函数等 |
.shstrtab | STRTAB | — | 节名字符串表 | 存 .text、.data 等节的名字 |
.bss 为什么不占文件空间?
未初始化的全局变量(如 int arr[10000];)在程序加载前全是 0,没必要在文件里存 40000 个零字节——节头表只记录它的大小,OS 加载时 mmap 一块清零的匿名页就行了。这是节省磁盘空间的经典优化。
段(Segment)与节的关系
可执行文件中,链接器把多个节 合并 进少数几个段(Segment),OS 按段来 mmap:
readelf -l a.out # 查看程序头表(段)
LOAD 0x000000 0x400000 0x400000 0x0002e5 0x0002e5 R E # 代码段
LOAD 0x000f10 0x600f10 0x600f10 0x000118 0x000120 RW # 数据段
DYNAMIC 0x000f28 0x600f28 0x600f28 0x0000d0 0x0000d0 RW
flowchart LR
subgraph "ELF 文件(节视图,链接器用)"
T[".text"]
R[".rodata"]
D[".data"]
B[".bss"]
G[".got"]
P[".plt"]
end
subgraph "进程内存(段视图,OS 加载)"
CS["代码段 (R+X)\n.text + .rodata + .plt"]
DS["数据段 (R+W)\n.data + .bss + .got"]
end
T --> CS
R --> CS
P --> CS
D --> DS
B --> DS
G --> DS
节与段的对应关系由 Program Header Table 中每条 LOAD 条目的 filesz/memsz 和偏移量决定。
符号表(.symtab)结构
readelf -s a.out | head -20
Symbol table '.symtab' contains 67 entries:
Num: Value Size Type Bind Vis Ndx Name
...
47: 0000000000401136 21 FUNC GLOBAL DEFAULT 14 main
48: 0000000000401120 22 FUNC GLOBAL DEFAULT 14 add
51: 0000000000404028 4 OBJECT GLOBAL DEFAULT 24 counter
| 字段 | 含义 |
|---|---|
Value | 符号的虚拟地址(.o 中为 0,链接后为真实地址) |
Size | 函数/变量的字节大小 |
Type | FUNC=函数、OBJECT=变量/数组、NOTYPE=未知 |
Bind | GLOBAL=全局可见、LOCAL=文件内可见(static)、WEAK=弱符号 |
Ndx | 所属节的编号;UND=未定义(需从其他 .o 引入) |
弱符号(WEAK):定义了但允许被其他 .o 中同名的强符号覆盖,常用于提供可替换的默认实现(__attribute__((weak)))。
常用 ELF 探查命令速查
# 查看 ELF 头(文件类型、入口、架构)
readelf -h file.o
# 查看所有节及其大小、类型
readelf -S file.o
# 查看符号表
readelf -s file.o
# 查看重定位条目
readelf -r file.o
# 查看程序头表(段,仅可执行文件和 .so)
readelf -l a.out
# 反汇编 .text 节
objdump -d file.o
# 查看特定节的原始内容(如 .rodata)
objdump -s -j .rodata file.o
# 查看动态链接依赖
ldd a.out
# 查看 .dynamic 节(动态链接元信息)
readelf -d a.out
.o里的地址都是 0 开始的占位符,链接器负责合并所有.o、解析符号引用、填入真实虚拟地址——这就是 重定位。
6.6 完整实例:从 C 到二进制
int square(int x) { return x * x; }
编译为汇编(gcc -S -O1):
square:
movl %edi, %eax # eax = x(第1参数)
imull %edi, %eax # eax = x * x
ret
汇编为机器码(objdump -d):
0000000000000000 <square>:
0: 89 f8 mov %edi,%eax
2: 0f af c7 imul %edi,%eax
5: c3 ret
总共 6 字节——89 f8 0f af c7 c3。CPU 看到的就是这 48 个比特,不知道什么叫 square、int、x——变量名、类型信息全部在编译过程中被消化掉了,留下的只有纯粹的二进制指令。
七、运行:shell 如何加载并执行 ./a.out
前面几节讲的都是「程序还没跑起来」之前的事——编译、链接、ELF 格式。这一节回答最后一个问题:当你在终端敲下 ./a.out 回车,到 main 真正开始执行,中间发生了什么?
核心是一条系统调用链:fork → execve → 动态链接 → _start → main → exit → waitpid 回收。这正是 CS
7.1 一句话总览
shell 本身也是个普通用户进程。它不会「变身」成你的程序,而是 复制出一个子进程(fork),让子进程 把自己整个换成你的程序(execve),父进程在旁边 等着收尸(waitpid)。
shell ──fork()──► 子进程(此刻还是 shell 的副本)
│ │
waitpid() execve("./a.out", argv, envp)
│ │
│ 内核读 ELF → 建立内存映射 → ld.so 重定位
│ │
│ _start → __libc_start_main → main()
│ │
│ main return → exit() → _exit() 系统调用
│ │
waitpid 返回 ◄── 子进程终止(zombie),回收,echo $? 拿到退出码
7.2 六个关键步骤
| 步骤 | 谁在做 | 干了什么 |
|---|---|---|
| ① 解析命令 | shell | 拆词、判断内建/外部命令、路径解析、准备 argv/envp |
| ② fork | shell → 内核 | 写时复制出一个子进程,fork 返回两次 |
| ③ execve | 子进程 → 内核 | 检查 magic number,清空地址空间,按 program header 映射各段 |
| ④ 动态链接 | ld.so | 加载 .so、重定位 GOT/PLT(常用延迟绑定) |
| ⑤ 启动 | C 运行时 | _start → __libc_start_main → 调用 main |
| ⑥ 退出与回收 | 子进程 + shell | exit 收尾 → _exit 系统调用 → 父进程 waitpid 回收 |
⚠️ 两个容易混淆的点:
execve成功后永不返回——因为调用它的那段代码本身已经被新程序覆盖掉了,只有失败才返回-1。- 程序入口不是
main,而是_start——main只是 C 运行时帮你调用的一个普通函数。
7.3 交互式演示:从回车到 main
点下面的步骤逐帧看 shell + 内核 + 动态链接器是如何接力的。
./a.out 加载与执行的交互式分步演示
7.4 动手验证:自己写一遍 fork + execve
shell 启动程序的核心,用十几行 C 就能复刻:
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
pid_t pid = fork(); // ① 复制出子进程
if (pid == 0) {
// 子进程:换成 ./a.out
char *argv[] = {"./a.out", NULL};
execve("./a.out", argv, NULL); // ② 替换进程映像
perror("execve"); // 只有失败才会执行到这里
_exit(127);
} else {
int status;
waitpid(pid, &status, 0); // ③ 父进程回收子进程
printf("子进程退出码 = %d\n", WEXITSTATUS(status));
}
return 0;
}
这就是一个极简 shell 的雏形:fork 出分身、execve 换皮、waitpid 收尸——把这段循环包起来再读用户输入,就是 bash 最内核的那一圈。
7.5 常见困惑速查
| 问题 | 答案 |
|---|---|
为什么要写 ./? | 不带路径时 shell 只查 PATH,当前目录通常不在里面,会报 command not found |
execve 成功后返回什么? | 不返回——原代码已被覆盖,只有失败才返回 -1 |
main 是程序第一行执行的代码吗? | 不是,入口是 _start,main 之前还有运行时初始化 |
argc/argv 从哪来? | shell 准备 → execve 压进用户栈 → 运行时传给 main |
| 什么是僵尸进程? | 子进程已终止但父进程还没 waitpid 回收,残留退出状态的进程 |
| 静态链接和动态链接在这条链路的区别? | 静态链接跳过第 ④ 步,直接进 _start;动态链接要先由 ld.so 加载 .so 并重定位 |
八、单任务、多任务与进程切换
第七章讲的是「一个程序怎么跑起来」。但你的电脑上同时开着浏览器、编辑器、音乐播放器——它们是怎么「一起」运行的?这一节回答:多个程序如何共享一个 CPU。
8.1 单任务 vs 多任务
| 单任务系统 | 多任务系统 | |
|---|---|---|
| 同时运行的程序数 | 一次只能跑 1 个 | 看起来很多个「同时」跑 |
| 切换时机 | 当前程序跑完/退出,才能跑下一个 | 操作系统随时打断、切换 |
| 典型代表 | 早期 MS-DOS、裸机单片机 | Linux / Windows / macOS |
| 关键能力 | 无需调度 | 需要调度器 + 进程切换 |
单任务系统里,CPU 从头到尾被一个程序独占;多任务系统里,操作系统用一招「障眼法」让一个 CPU 看起来能同时干很多事。
8.2 并发 ≠ 并行
这是最容易混的一对概念:
- 并发(Concurrency):多个任务在同一段时间内交替推进。单核 CPU 靠飞快地切换,制造「同时进行」的错觉——其实任一瞬间只有一个在跑。
- 并行(Parallelism):多个任务在同一时刻真正一起跑。必须有多个核(多核 / 多 CPU)才能做到。
🍳 一个厨师同时照看煎蛋和煮面(一会儿翻蛋一会儿搅面)= 并发;两个厨师各做一样 = 并行。
单核多任务系统提供的是并发;多核才能同时提供并行 + 并发。
8.3 进程切换(上下文切换)
让单个 CPU 在多个进程间「轮流」执行,靠的就是 上下文切换(Context Switch)。所谓「上下文」,就是一个进程在某一瞬间跑到哪、用着哪些寄存器的全部现场:
- 程序计数器 PC / RIP:下一条要执行的指令地址
- 通用寄存器:
rax、rsp(栈指针)等的值 - 条件码、页表基址(CR3) 等 CPU 状态
这些现场被保存在内核里每个进程对应的 PCB(进程控制块,task_struct) 中。切换的本质就是:把当前进程的现场存起来 → 把下一个进程的现场恢复出来 → 跳过去继续跑——就像它从没被打断过。
进程 A 正在跑
│ 时钟中断 / 系统调用,陷入内核
▼
[保存 A 的上下文 → A 的 PCB] ← 存档
│ 调度器挑选下一个进程 B
▼
[从 B 的 PCB 恢复 B 的上下文] ← 读档
│ 返回用户态
▼
进程 B 接着上次的地方继续跑
8.4 动画演示:一个 CPU 如何「同时」跑两个进程
点「▶ 播放」,看一条 CPU 时间线如何在进程 A、B 之间分时轮转——橙色斜纹段就是上下文切换的开销。
单核多任务的进程切换(上下文切换)交互式动画
8.5 触发进程切换的三类事件
进程切换总是发生在内核态,但「进内核」的入口有三种(即第二章提到的异常控制流):
| 触发源 | 例子 | 是否当前进程主动 |
|---|---|---|
| 中断(Interrupt) | 时钟中断、键盘/网卡 I/O 完成 | 否,外部异步发生 |
| 陷阱(Trap)/ 系统调用 | read()、write()、fork() | 是,程序主动请求内核 |
| 故障(Fault) | 缺页(page fault)、除零 | 否,执行中被动触发 |
其中时钟中断是抢占式多任务的关键——它保证哪怕某个进程写了死循环,操作系统也总能定期夺回控制权,不会卡死整台机器。
8.6 关键概念速查
| 概念 | 一句话 |
|---|---|
| 并发 | 一段时间内交替推进,单核即可,靠切换造成「同时」错觉 |
| 并行 | 同一时刻真正一起跑,必须多核 |
| 上下文 | 进程的执行现场:PC、寄存器、栈指针、页表基址等 |
| PCB | 内核为每个进程维护的档案(Linux 里是 task_struct) |
| 上下文切换 | 存当前进程现场 → 调度 → 恢复下一个进程现场 |
| 时间片 | 分给每个进程的一小段 CPU 时间,到点触发时钟中断 |
| 抢占式调度 | 操作系统能强行打断进程(靠时钟中断),对比协作式(进程自觉让出) |
| 进程 vs 线程切换 | 线程切换更轻:同进程线程共享地址空间,不用切页表 |
九、UNIX 进程层次:一棵从 init 长出来的树
第七章讲了「fork 复制出子进程」,但有个细节没展开:子进程的父进程是谁?谁又是它的父进程? 顺着这条线往上追,会发现整个系统的所有进程构成一棵树——这就是 UNIX 进程模型最优雅的地方。
到这一节,你会发现「计算机系统」已经彻底滑进「操作系统」了 😂,这正是 CS
后半本的魅力。
9.1 一切的祖先:PID 1
内核启动后,会亲手创建第一个用户态进程,PID 永远是 1:
- 传统系统叫 init,现代 Linux 多是 systemd,容器里可能是你的应用本身
- 它是所有其他进程的最终祖先——其余每个进程都是被别人
fork出来的 - 系统中唯一没有「普通父进程」 的进程
之后的一切都靠 fork 繁衍:init fork 出登录服务、shell,shell 再 fork 出你的命令……于是形成一棵进程树(process tree)。
systemd (PID 1)
├── sshd
│ └── bash (你的终端)
│ ├── vim
│ └── ./a.out
├── cron
└── nginx
├── nginx worker
└── nginx worker
用 pstree 就能看到这棵真实的树:
pstree -p # 带 PID 显示整棵进程树
ps -ef # 每行的 PPID 列就是「父进程 PID」
echo $$ # 当前 shell 自己的 PID
这棵树太大啦~
9.2 父子关系:PID 与 PPID
每个进程都记着两个关键号码:
| 字段 | 含义 |
|---|---|
| PID | 自己的进程号 |
| PPID | 父进程的进程号(Parent PID) |
fork 的那一刻,新进程的 PPID 就被设成调用 fork 的那个进程。顺着 PPID 一路往上,最终都会走到 PID 1。程序里用 getpid() / getppid() 就能拿到这两个值。
9.3 进程组、会话与终端
光有父子树还不够,UNIX 还把进程分了层级单位,用来管理「一组相关的进程」:
- 进程组(Process Group):一条管道命令
ls | grep foo | wc -l里的几个进程同属一个进程组,方便整组收发信号(按Ctrl-C是把SIGINT发给整个前台进程组)。 - 会话(Session):一次登录/一个终端对应一个会话,包含一个前台进程组和若干后台进程组。
- 控制终端:会话关联的那个终端;会话首进程(session leader)通常是 shell。
会话 (Session)
├── 前台进程组 ← Ctrl-C / Ctrl-Z 作用于它
│ └── vim
└── 后台进程组 ← 命令后加 & 的那些
└── ./a.out &
9.4 交互式进程树:孤儿与僵尸
点树上的节点看它的 PID/PPID,再用下方按钮制造「孤儿」和「僵尸」,观察这棵树怎么变化。
UNIX 进程层次:可点击的进程树(孤儿 / 僵尸)交互式演示
9.5 三种「非正常」结局对比
| 孤儿进程 (Orphan) | 僵尸进程 (Zombie) | 守护进程 (Daemon) | |
|---|---|---|---|
| 触发 | 父进程先退出 | 子进程先终止、父没 wait | 程序主动脱离终端 |
| 是否还在运行 | 是 | 否(已终止) | 是 |
| PPID 归宿 | 被 init(PID 1)收养 | 看父进程,若父退出则归 init | 最终归 1 |
| 危害 | 一般无害 | 占 PID,多了会耗尽 | 设计如此,无害 |
| 清除方式 | 不需要,正常运行 | 父进程 wait,或交给 init | 不需要,长期运行 |
9.6 关键命令与系统调用速查
pstree -p # 树状查看进程层次
ps -ef # 列出所有进程,含 PPID 列
ps aux | grep Z # 找僵尸进程(STAT = Z / defunct)
echo $$ # 当前 shell 的 PID
echo $PPID # 当前 shell 的父进程 PID
| 系统调用 | 作用 |
|---|---|
fork() | 复制出子进程,建立父子关系 |
getpid() / getppid() | 取自己 / 父进程的 PID |
wait() / waitpid() | 回收子进程,消除僵尸 |
setsid() | 自立为新会话首,脱离控制终端(守护进程关键一步) |
setpgid() | 设置进程组,用于作业控制 |
把第七章(
fork/execve/waitpid)、第八章(调度/切换)和这一章(进程树/孤儿/僵尸/守护进程)连起来看,UNIX 进程模型的全貌就完整了:一个进程怎么生(fork)、怎么活(execve + 调度)、怎么死(exit + wait)、死后归谁管(init 收养)。
十、信号:进程间的「软件中断」
前面讲的中断(时钟、I/O)是硬件层面的异常控制流;信号则是它的软件版——内核或进程向另一个进程发送的一个「小通知」,打断它正常的执行流去处理某件事。你每天都在用:按 Ctrl-C 终止程序、kill 掉卡死的进程,背后都是信号。
信号是 CS
「异常控制流」一章的压轴。它把前面所有概念串了起来:进程(谁收谁发)、进程组/会话(Ctrl-C 发给谁)、回收(SIGCHLD 通知父进程收尸)。
10.1 信号是什么
信号是一个很小的消息(本质上就是一个编号 + 一点点信息),用来通知进程「发生了某种事件」:
- 它是异步的——可能在进程执行到任何一条指令时突然到来
- 每种信号有个编号和名字,如
2 = SIGINT、9 = SIGKILL - 进程收到后,可以默认处理、忽略、或自定义处理
可以把信号理解成「打断你正在做的事,塞给你一张便条」——便条内容(信号种类)决定你该作何反应。
10.2 常见信号一览
| 编号 | 名字 | 触发场景 | 默认行为 | 能否捕获/忽略 |
|---|---|---|---|---|
| 2 | SIGINT | 终端按 Ctrl-C | 终止 | 可 |
| 3 | SIGQUIT | 终端按 Ctrl-\ | 终止 + core | 可 |
| 9 | SIGKILL | kill -9 | 强制终止 | 不可 |
| 11 | SIGSEGV | 非法内存访问(段错误) | 终止 + core | 可 |
| 13 | SIGPIPE | 写已关闭的管道 | 终止 | 可 |
| 15 | SIGTERM | kill 默认信号 | 终止(可优雅退出) | 可 |
| 17 | SIGCHLD | 子进程终止/停止 | 忽略 | 可 |
| 18/19 | SIGCONT/SIGSTOP | 继续 / 暂停(Ctrl-Z 是 SIGTSTP) | 继续 / 暂停 | SIGSTOP 不可 |
🔑 记住两个「特权信号」:
SIGKILL(9)和SIGSTOP(19)既不能被捕获,也不能被忽略或阻塞——这是操作系统留给自己的「终极后手」,保证任何失控进程都能被强制干掉或暂停。
10.3 信号的一生:发送 → 待决 → 处理
一个信号从产生到被处理,要经历「发送 → 待决 → (可能阻塞)→ 投递」几个阶段。下面这个小沙盘可以亲手发信号、切换掩码,直接看待决位图怎么亮、怎么灭。
信号的发送、待决、阻塞(掩码)与投递交互式演示
🛡️ 信号掩码 Blocked(点击切换阻塞)
📥 待决 Pending(只读)
10.4 信号 vs 硬件中断
| 硬件中断 | 信号 | |
|---|---|---|
| 层面 | 硬件 → 内核 | 内核 → 进程(软件) |
| 接收者 | CPU / 内核 | 某个进程 |
| 处理者 | 内核的中断处理程序 | 进程的信号处理函数(或默认动作) |
| 例子 | 时钟中断、键盘中断 | SIGINT、SIGSEGV、SIGCHLD |
可以说,信号是「进程级别的中断」——内核把硬件中断这套异步打断机制,搬到了用户进程的世界里。
10.5 关键命令与系统调用速查
kill -l # 列出所有信号名与编号
kill -SIGTERM <pid> # 发 SIGTERM(默认就是它)
kill -9 <pid> # 发 SIGKILL,强制终止
kill -SIGSTOP <pid> # 暂停;kill -SIGCONT <pid> 恢复
kill -- -<pgid> # 给整个进程组发信号(注意前面的负号)
trap 'echo bye' SIGINT # shell 里捕获信号
| 系统调用 | 作用 |
|---|---|
kill(pid, sig) / raise(sig) | 给别的进程 / 给自己发信号 |
signal() / sigaction() | 注册信号处理方式(优先用 sigaction) |
sigprocmask() | 阻塞 / 解除阻塞信号(设置掩码) |
sigpending() | 查看当前待决的信号 |
pause() / sigsuspend() | 挂起进程,等待信号到来 |
alarm() | 定时给自己发 SIGALRM |
至此,「程序执行」从
./a.out一路讲到信号,异常控制流的四种形式——中断、陷阱、故障、信号——就全齐了。前三种是 CPU/内核层面的「硬」机制,信号是它们在进程世界的「软」投影。
十一、信号的陷阱:写对一个处理函数有多难
信号好玩,也最容易出错。原因只有一句话:信号处理函数(handler)和你的主程序是「并发」的——handler 可能在主程序执行到任意一条指令的缝隙里突然插进来运行。这跟多线程并发是同一类问题,于是多线程的所有坑,信号这里几乎都有。
这一节是 CS
里出了名的「读着简单、写对极难」。看完你会理解:为什么教科书反复强调「handler 里能做的事少得可怜」。
11.1 根源:handler 会「插队」
主程序正跑着,信号一到,CPU 立刻跳去执行 handler,handler 跑完再回到主程序被打断的那条指令继续。问题是:
- 主程序和 handler 共享全局变量、共享
errno、共享堆、共享标准库内部状态 - 主程序的某个操作可能只做了一半,就被 handler 打断了
于是就有了下面这个最经典的坑。
11.2 沙盘:亲手制造一次「丢失的自增」
count++ 看着是一条语句,实际是三步:① 从内存读 count 到寄存器 → ② 寄存器 +1 → ③ 写回内存。如果信号在 ①③ 之间插进来,灾难就发生了。
下面这个沙盘:左边主程序在做 count++,你可以单步执行;任意时刻点「⚡ 信号打断」让 handler 也做一次 count++。试试在主程序「读完、还没写回」时触发信号——看最终 count 对不对。
信号处理函数与主程序并发导致丢失更新的交互式沙盘
🧩 主程序 count++
⚡ 信号处理函数 handler
count 做一次 ++。它会在主程序的任意缝隙插进来运行——包括主程序「读完、还没写回」的那一刻。
复现「丢失更新」的操作:主程序单步(读 count=0)→ 信号打断(内存变 1)→ 主程序单步两次(寄存器 0+1=1,写回 1)。两次自增,结果却是 1。这就是 handler 和主程序共享数据时的竞态。
11.3 陷阱清单
陷阱一:handler 里只能调用「异步信号安全」函数
handler 可能打断主程序里的 printf/malloc,而这些函数不可重入(内部有全局缓冲、锁)。若 handler 里又调用它们,可能死锁或数据损坏。
// ❌ 危险:printf/malloc 不是 async-signal-safe
void handler(int sig) { printf("got signal\n"); }
// ✅ 安全:只用 write() 这类 async-signal-safe 函数
void handler(int sig) { write(STDOUT_FILENO, "got\n", 4); }
安全函数清单见
man 7 signal-safety——很短,printf、malloc、exit都不在里面。
陷阱二:handler 必须保存并恢复 errno
handler 里调用的系统调用可能改写全局 errno,破坏主程序刚设置的值。
void handler(int sig) {
int saved = errno; // 进门先存
waitpid(-1, NULL, WNOHANG);
errno = saved; // 出门再还
}
陷阱三:共享数据要用 volatile sig_atomic_t
主程序和 handler 共享的标志位,必须是原子读写且不被编译器优化掉的类型。最稳的模式是:handler 只设一个标志,真正的活儿留给主循环干。
volatile sig_atomic_t got_sigint = 0;
void handler(int sig) { got_sigint = 1; } // handler 只做这一件事
int main() {
while (!got_sigint) { /* 干活 */ }
cleanup(); // 回到主流程再清理
}
陷阱四:信号会「丢失」——不排队
普通信号不计数:handler 还没处理时连来 3 个 SIGCHLD,最后只会触发一次 handler。所以回收子进程必须用 while 一次收干净:
void sigchld_handler(int sig) {
int saved = errno;
while (waitpid(-1, NULL, WNOHANG) > 0) // 循环!不能只 wait 一次
;
errno = saved;
}
陷阱五:被打断的慢系统调用返回 EINTR
read/accept 等阻塞调用被信号打断时会提前返回、errno == EINTR。要么手动重试,要么注册时加 SA_RESTART 让内核自动重启。
// 手动重试
while ((n = read(fd, buf, len)) < 0 && errno == EINTR)
;
// 或注册时:act.sa_flags = SA_RESTART;
11.4 用信号「响应内部事件」
陷阱之外,信号也是程序对自身/系统事件做出反应的优雅手段——这才是它好玩的地方:
| 想响应的事件 | 用的信号 / 机制 | 典型用途 |
|---|---|---|
| 超时 / 定时 | alarm() / setitimer() → SIGALRM | 给阻塞操作加超时、做周期任务 |
| 子进程结束 | SIGCHLD | 异步回收,避免僵尸(见第九、十章) |
| 配置热重载 | SIGHUP | nginx/sshd 收到 SIGHUP 重读配置不重启 |
| 优雅退出 | SIGTERM + handler | 收到后关连接、刷盘、再退出 |
| 段错误兜底 | SIGSEGV + siglongjmp | 捕获非法访问、跳回安全点(JIT/沙箱常用) |
| 浮点异常 | SIGFPE | 除零、溢出时自定义处理 |
| 窗口大小变化 | SIGWINCH | 终端程序重绘界面 |
例:给阻塞读加一个 5 秒超时
void on_alarm(int sig) { /* 什么都不做,只为打断 read */ }
signal(SIGALRM, on_alarm);
alarm(5); // 5 秒后给自己发 SIGALRM
ssize_t n = read(fd, buf, len); // 若 5 秒没数据,被 SIGALRM 打断,返回 EINTR
alarm(0); // 取消定时器
if (n < 0 && errno == EINTR) puts("读取超时!");
例:从 SIGSEGV 中「起死回生」
sigjmp_buf env;
void segv_handler(int sig) { siglongjmp(env, 1); } // 跳回安全点
if (sigsetjmp(env, 1) == 0) {
sigaction(SIGSEGV, &act, NULL);
*(int*)0 = 42; // 故意非法写——通常会崩溃
} else {
puts("捕获了段错误,程序继续活着"); // siglongjmp 跳到这里
}
这类「故障 → 信号 → 跳回安全点」的模式,正是异常控制流的精髓:把硬件级的故障(fault),变成程序可以优雅处理的软件事件。沙箱、JIT、脚本解释器都靠它兜底。
11.5 现代替代方案:把信号变回「文件」
信号的异步性是万恶之源。现代 Linux 提供了把信号「同步化」的机制,让你能在主循环里像处理普通 I/O 一样处理它们,绕开 handler 的所有陷阱:
| 机制 | 思路 |
|---|---|
| self-pipe trick | handler 里只 write 一个字节到管道,主循环用 select/epoll 读这个管道 |
signalfd() | 把信号变成一个可读的文件描述符,直接丢进 epoll |
timerfd() | 把定时器也变成文件描述符,替代 SIGALRM |
pidfd | 用文件描述符监控子进程退出,替代 SIGCHLD |
这也是事件循环(libevent、epoll、Node.js 等)处理信号的标准做法——「一切皆文件」再次拯救世界。
11.6 一句话记住这些坑
handler 是「闯进你家的不速之客」:它来得突然(异步)、待得短(只设标志)、不能乱碰东西(只调安全函数、存好 errno)、还可能漏掉(信号不排队,要 while 收干净)。 想省心,就用
signalfd/self-pipe 把它请到「主循环」这张正式的会客桌上谈。